Compare commits
3 Commits
97247f824c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e5fa7f752 | |||
| 1d20fb8650 | |||
| ce5328fe34 |
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
|
||||||
536
.claude/commands/unit-tester.md
Normal file
536
.claude/commands/unit-tester.md
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
# 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: Required Reading for Control Render Tests
|
||||||
|
|
||||||
|
**Before writing ANY render tests for Controls, you MUST:**
|
||||||
|
|
||||||
|
1. **Read the matcher documentation**: `docs/testing_rendered_components.md`
|
||||||
|
2. **Understand the key concepts**:
|
||||||
|
- How `matches()` and `find()` work
|
||||||
|
- When to use predicates (Contains, StartsWith, AnyValue, etc.)
|
||||||
|
- How to test only what matters (not every detail)
|
||||||
|
- How to read error messages with `^^^` markers
|
||||||
|
3. **Apply the best practices** detailed below
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **UTR-11.1 : Pattern de test en trois étapes (RÈGLE FONDAMENTALE)**
|
||||||
|
|
||||||
|
**Principe :** C'est le pattern par défaut à appliquer pour tous les tests de rendu. Les autres règles sont des compléments à ce pattern.
|
||||||
|
|
||||||
|
**Les trois étapes :**
|
||||||
|
1. **Extraire l'élément à tester** avec `find_one()` ou `find()` à partir du rendu global
|
||||||
|
2. **Définir la structure attendue** avec `expected = ...`
|
||||||
|
3. **Comparer** avec `assert matches(element, expected)`
|
||||||
|
|
||||||
|
**Pourquoi :** Ce pattern permet des messages d'erreur clairs et sépare la recherche de l'élément de la validation de sa structure.
|
||||||
|
|
||||||
|
**Exemple :**
|
||||||
|
```python
|
||||||
|
# ✅ BON - Pattern en trois étapes
|
||||||
|
def test_header_has_two_sides(self, layout):
|
||||||
|
"""Test that there is a left and right header section."""
|
||||||
|
# Étape 1 : Extraire l'élément à tester
|
||||||
|
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||||
|
|
||||||
|
# Étape 2 : Définir la structure attendue
|
||||||
|
expected = Header(
|
||||||
|
Div(id=f"{layout._id}_hl"),
|
||||||
|
Div(id=f"{layout._id}_hr"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Étape 3 : Comparer
|
||||||
|
assert matches(header, expected)
|
||||||
|
|
||||||
|
# ❌ À ÉVITER - Tout imbriqué en une ligne
|
||||||
|
def test_header_has_two_sides(self, layout):
|
||||||
|
assert matches(
|
||||||
|
find_one(layout.render(), Header(cls=Contains("mf-layout-header"))),
|
||||||
|
Header(Div(id=f"{layout._id}_hl"), Div(id=f"{layout._id}_hr"))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note :** Cette règle s'applique à presque tous les tests. Les autres règles ci-dessous complètent ce pattern fondamental.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **COMMENT CHERCHER LES ÉLÉMENTS**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **UTR-11.2 : Privilégier la recherche par ID**
|
||||||
|
|
||||||
|
**Principe :** Toujours chercher un élément par son `id` quand il en a un, plutôt que par classe ou autre attribut.
|
||||||
|
|
||||||
|
**Pourquoi :** Plus robuste, plus rapide, et ciblé (un ID est unique).
|
||||||
|
|
||||||
|
**Exemple :**
|
||||||
|
```python
|
||||||
|
# ✅ BON - recherche par ID
|
||||||
|
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||||
|
|
||||||
|
# ❌ À ÉVITER - recherche par classe quand un ID existe
|
||||||
|
drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer")))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **UTR-11.3 : Utiliser `find_one()` vs `find()` selon le contexte**
|
||||||
|
|
||||||
|
**Principe :**
|
||||||
|
- `find_one()` : Quand vous cherchez un élément unique et voulez tester sa structure complète
|
||||||
|
- `find()` : Quand vous cherchez plusieurs éléments ou voulez compter/vérifier leur présence
|
||||||
|
|
||||||
|
**Exemples :**
|
||||||
|
```python
|
||||||
|
# ✅ BON - find_one pour structure unique
|
||||||
|
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
|
||||||
|
expected = Header(...)
|
||||||
|
assert matches(header, expected)
|
||||||
|
|
||||||
|
# ✅ BON - find pour compter
|
||||||
|
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||||
|
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **COMMENT SPÉCIFIER LA STRUCTURE ATTENDUE**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **UTR-11.4 : Toujours utiliser `Contains()` pour les attributs `cls` et `style`**
|
||||||
|
|
||||||
|
**Principe :**
|
||||||
|
- Pour `cls` : Les classes CSS peuvent être dans n'importe quel ordre. Testez uniquement les classes importantes avec `Contains()`.
|
||||||
|
- Pour `style` : Les propriétés CSS peuvent être dans n'importe quel ordre. Testez uniquement les propriétés importantes avec `Contains()`.
|
||||||
|
|
||||||
|
**Pourquoi :** Évite les faux négatifs dus à l'ordre des classes/propriétés ou aux espaces.
|
||||||
|
|
||||||
|
**Exemples :**
|
||||||
|
```python
|
||||||
|
# ✅ BON - Contains pour cls (une ou plusieurs classes)
|
||||||
|
expected = Div(cls=Contains("mf-layout-drawer"))
|
||||||
|
expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"))
|
||||||
|
|
||||||
|
# ✅ BON - Contains pour style
|
||||||
|
expected = Div(style=Contains("width: 250px"))
|
||||||
|
|
||||||
|
# ❌ À ÉVITER - test exact des classes
|
||||||
|
expected = Div(cls="mf-layout-drawer mf-layout-left-drawer")
|
||||||
|
|
||||||
|
# ❌ À ÉVITER - test exact du style complet
|
||||||
|
expected = Div(style="width: 250px; overflow: hidden; display: flex;")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **UTR-11.5 : Utiliser `TestIcon()` pour tester la présence d'une icône**
|
||||||
|
|
||||||
|
**Principe :** Utilisez `TestIcon("icon_name")` pour tester la présence d'une icône SVG dans le rendu.
|
||||||
|
|
||||||
|
**Le paramètre `name` :**
|
||||||
|
- **Nom exact** : Utilisez le nom exact de l'import (ex: `TestIcon("panel_right_expand20_regular")`) pour valider une icône spécifique
|
||||||
|
- **`name=""`** (chaîne vide) : Valide **n'importe quelle icône**. Le test sera passant dès que la structure affichant une icône sera trouvée, peu importe laquelle.
|
||||||
|
- **JAMAIS `name="svg"`** : Cela causera des échecs de test
|
||||||
|
|
||||||
|
**Exemples :**
|
||||||
|
```python
|
||||||
|
from myfasthtml.icons.fluent import panel_right_expand20_regular
|
||||||
|
|
||||||
|
# ✅ BON - Tester une icône spécifique
|
||||||
|
expected = Header(
|
||||||
|
Div(
|
||||||
|
TestIcon("panel_right_expand20_regular"),
|
||||||
|
cls=Contains("flex", "gap-1")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ BON - Tester la présence de n'importe quelle icône
|
||||||
|
expected = Div(
|
||||||
|
TestIcon(""), # Accepte n'importe quelle icône
|
||||||
|
cls=Contains("icon-wrapper")
|
||||||
|
)
|
||||||
|
|
||||||
|
# ❌ À ÉVITER - name="svg"
|
||||||
|
expected = Div(TestIcon("svg")) # ERREUR : causera un échec
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **UTR-11.6 : Utiliser `TestScript()` pour tester les scripts JavaScript**
|
||||||
|
|
||||||
|
**Principe :** Utilisez `TestScript(code_fragment)` pour vérifier la présence de code JavaScript. Testez uniquement le fragment important, pas le script complet.
|
||||||
|
|
||||||
|
**Exemple :**
|
||||||
|
```python
|
||||||
|
# ✅ BON - TestScript avec fragment important
|
||||||
|
script = find_one(layout.render(), Script())
|
||||||
|
expected = TestScript(f"initResizer('{layout._id}');")
|
||||||
|
assert matches(script, expected)
|
||||||
|
|
||||||
|
# ❌ À ÉVITER - tester tout le contenu du script
|
||||||
|
expected = Script("(function() { const id = '...'; initResizer(id); })()")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **COMMENT DOCUMENTER LES TESTS**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **UTR-11.7 : Justifier le choix des éléments testés**
|
||||||
|
|
||||||
|
**Principe :** Dans la section de documentation du test (après le docstring de description), expliquez **pourquoi chaque élément ou attribut testé a été choisi**. Qu'est-ce qui le rend important pour la fonctionnalité ?
|
||||||
|
|
||||||
|
**Ce qui compte :** Pas la formulation exacte ("Why these elements matter" vs "Why this test matters"), mais **l'explication de la pertinence de ce qui est testé**.
|
||||||
|
|
||||||
|
**Exemples :**
|
||||||
|
```python
|
||||||
|
def test_empty_layout_is_rendered(self, layout):
|
||||||
|
"""Test that Layout renders with all main structural sections.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script)
|
||||||
|
- _id: Essential for layout identification and resizer initialization
|
||||||
|
- cls="mf-layout": Root CSS class for layout styling
|
||||||
|
"""
|
||||||
|
expected = Div(...)
|
||||||
|
assert matches(layout.render(), expected)
|
||||||
|
|
||||||
|
def test_left_drawer_is_rendered_when_open(self, layout):
|
||||||
|
"""Test that left drawer renders with correct classes when open.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- _id: Required for targeting drawer in HTMX updates
|
||||||
|
- cls Contains "mf-layout-drawer": Base drawer class for styling
|
||||||
|
- cls Contains "mf-layout-left-drawer": Left-specific drawer positioning
|
||||||
|
- style Contains width: Drawer width must be applied for sizing
|
||||||
|
"""
|
||||||
|
layout._state.left_drawer_open = True
|
||||||
|
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
_id=f"{layout._id}_ld",
|
||||||
|
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"),
|
||||||
|
style=Contains("width: 250px")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(drawer, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Points clés :**
|
||||||
|
- Expliquez pourquoi l'attribut/élément est important (fonctionnalité, HTMX, styling, etc.)
|
||||||
|
- Pas besoin de suivre une formulation rigide
|
||||||
|
- L'important est la **justification du choix**, pas le format
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **UTR-11.8 : Tests de comptage avec messages explicites**
|
||||||
|
|
||||||
|
**Principe :** Quand vous comptez des éléments avec `assert len()`, ajoutez TOUJOURS un message explicite qui explique pourquoi ce nombre est attendu.
|
||||||
|
|
||||||
|
**Exemple :**
|
||||||
|
```python
|
||||||
|
# ✅ BON - message explicatif
|
||||||
|
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||||
|
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||||
|
|
||||||
|
dividers = find(content, Div(cls="divider"))
|
||||||
|
assert len(dividers) >= 1, "Groups should be separated by dividers"
|
||||||
|
|
||||||
|
# ❌ À ÉVITER - pas de message
|
||||||
|
assert len(resizers) == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **AUTRES RÈGLES IMPORTANTES**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Mandatory render test rules:**
|
||||||
|
|
||||||
|
1. **Test naming**: Use descriptive names like `test_empty_layout_is_rendered()` not `test_layout_renders_with_all_sections()`
|
||||||
|
|
||||||
|
2. **Documentation format**: Every render test MUST have a docstring with:
|
||||||
|
- First line: Brief description of what is being tested
|
||||||
|
- Blank line
|
||||||
|
- Justification section explaining why tested elements matter (see UTR-11.7)
|
||||||
|
- List of important elements/attributes being tested with explanations (in English)
|
||||||
|
|
||||||
|
3. **No inline comments**: Do NOT add comments on each line of the expected structure (except for structural clarification in global layout tests like `# left drawer`)
|
||||||
|
|
||||||
|
4. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components
|
||||||
|
|
||||||
|
5. **Test organization for Controls**: Organize tests into thematic classes:
|
||||||
|
- `TestControlBehaviour`: Tests for control behavior and logic
|
||||||
|
- `TestControlRender`: Tests for control HTML rendering
|
||||||
|
|
||||||
|
6. **Fixture usage**: In `TestControlRender`, use a pytest fixture to create the control instance:
|
||||||
|
```python
|
||||||
|
class TestControlRender:
|
||||||
|
@pytest.fixture
|
||||||
|
def layout(self, root_instance):
|
||||||
|
return Layout(root_instance, app_name="Test App")
|
||||||
|
|
||||||
|
def test_something(self, layout):
|
||||||
|
# layout is injected automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### **Résumé : Les 8 règles UTR-11**
|
||||||
|
|
||||||
|
**Pattern fondamental**
|
||||||
|
- **UTR-11.1** : Pattern en trois étapes (extraire → définir expected → comparer)
|
||||||
|
|
||||||
|
**Comment chercher**
|
||||||
|
- **UTR-11.2** : Privilégier recherche par ID
|
||||||
|
- **UTR-11.3** : `find_one()` vs `find()` selon contexte
|
||||||
|
|
||||||
|
**Comment spécifier**
|
||||||
|
- **UTR-11.4** : Toujours `Contains()` pour `cls` et `style`
|
||||||
|
- **UTR-11.5** : `TestIcon()` pour tester la présence d'icônes
|
||||||
|
- **UTR-11.6** : `TestScript()` pour JavaScript
|
||||||
|
|
||||||
|
**Comment documenter**
|
||||||
|
- **UTR-11.7** : Justifier le choix des éléments testés
|
||||||
|
- **UTR-11.8** : Messages explicites pour `assert len()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**When proposing render tests:**
|
||||||
|
- Reference specific patterns from the documentation
|
||||||
|
- Explain why you chose to test certain elements and not others
|
||||||
|
- Justify the use of predicates vs exact values
|
||||||
|
- Always include justification documentation (see UTR-11.7)
|
||||||
|
|
||||||
|
### UTR-12: 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
|
||||||
455
CLAUDE.md
Normal file
455
CLAUDE.md
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
MyFastHtml is a Python utility library that simplifies FastHTML application development by providing:
|
||||||
|
- Command management system for client-server interactions
|
||||||
|
- Bidirectional data binding system
|
||||||
|
- Predefined authentication pages and routes
|
||||||
|
- Interactive control helpers
|
||||||
|
- Session-based instance management
|
||||||
|
|
||||||
|
**Tech Stack**: Python 3.12+, FastHTML, HTMX, DaisyUI 5, Tailwind CSS 4
|
||||||
|
|
||||||
|
## Development Workflow and Guidelines
|
||||||
|
|
||||||
|
### Development Process
|
||||||
|
|
||||||
|
**Code must always be testable**. Before writing any code:
|
||||||
|
|
||||||
|
1. **Explain available options first** - Present different approaches to solve the problem
|
||||||
|
2. **Wait for validation** - Ensure mutual understanding of requirements before implementation
|
||||||
|
3. **No code without approval** - Only proceed after explicit validation
|
||||||
|
|
||||||
|
### Collaboration Style
|
||||||
|
|
||||||
|
**Ask questions to clarify understanding or suggest alternative approaches:**
|
||||||
|
- Ask questions **one at a time**
|
||||||
|
- Wait for complete answer before asking the next question
|
||||||
|
- Indicate progress: "Question 1/5" if multiple questions are needed
|
||||||
|
- Never assume - always clarify ambiguities
|
||||||
|
|
||||||
|
### Communication
|
||||||
|
|
||||||
|
**Conversations**: French or English
|
||||||
|
**Code, documentation, comments**: English only
|
||||||
|
|
||||||
|
### Code Standards
|
||||||
|
|
||||||
|
**Follow PEP 8** conventions strictly:
|
||||||
|
- Variable and function names: `snake_case`
|
||||||
|
- Explicit, descriptive naming
|
||||||
|
- **No emojis in code**
|
||||||
|
|
||||||
|
**Documentation**:
|
||||||
|
- Use Google or NumPy docstring format
|
||||||
|
- Document all public functions and classes
|
||||||
|
- Include type hints where applicable
|
||||||
|
|
||||||
|
### Dependency Management
|
||||||
|
|
||||||
|
**When introducing new dependencies:**
|
||||||
|
- List all external dependencies explicitly
|
||||||
|
- Propose alternatives using Python standard library when possible
|
||||||
|
- Explain why each dependency is needed
|
||||||
|
|
||||||
|
### Unit Testing with pytest
|
||||||
|
|
||||||
|
**Test naming patterns:**
|
||||||
|
- Passing tests: `test_i_can_xxx` - Tests that should succeed
|
||||||
|
- Failing tests: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
|
||||||
|
|
||||||
|
**Test structure:**
|
||||||
|
- Use **functions**, not classes (unless inheritance is required)
|
||||||
|
- Before writing tests, **list all planned tests with explanations**
|
||||||
|
- Wait for validation before implementing tests
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```python
|
||||||
|
def test_i_can_create_command_with_valid_name():
|
||||||
|
"""Test that a command can be created with a valid name."""
|
||||||
|
cmd = Command("valid_name", "description", lambda: None)
|
||||||
|
assert cmd.name == "valid_name"
|
||||||
|
|
||||||
|
def test_i_cannot_create_command_with_empty_name():
|
||||||
|
"""Test that creating a command with empty name raises ValueError."""
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Command("", "description", lambda: None)
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Management
|
||||||
|
|
||||||
|
**Always specify the full file path** when adding or modifying files:
|
||||||
|
```
|
||||||
|
✅ Modifying: src/myfasthtml/core/commands.py
|
||||||
|
✅ Creating: tests/core/test_new_feature.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
**When errors occur:**
|
||||||
|
1. **Explain the problem clearly first**
|
||||||
|
2. **Do not propose a fix immediately**
|
||||||
|
3. **Wait for validation** that the diagnosis is correct
|
||||||
|
4. Only then propose solutions
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
pytest tests/core/test_bindings.py
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
pytest tests/core/test_bindings.py::test_function_name
|
||||||
|
|
||||||
|
# Run tests with verbose output
|
||||||
|
pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cleaning
|
||||||
|
```bash
|
||||||
|
# Clean build artifacts and cache
|
||||||
|
make clean
|
||||||
|
|
||||||
|
# Clean package distribution files
|
||||||
|
make clean-package
|
||||||
|
|
||||||
|
# Clean test artifacts (.sesskey, test databases)
|
||||||
|
make clean-tests
|
||||||
|
|
||||||
|
# Clean everything including source artifacts
|
||||||
|
make clean-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### Package Building
|
||||||
|
```bash
|
||||||
|
# Build distribution
|
||||||
|
python -m build
|
||||||
|
|
||||||
|
# Install in development mode
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
### Core System: Commands
|
||||||
|
|
||||||
|
Commands abstract HTMX interactions by encapsulating server-side actions. Located in `src/myfasthtml/core/commands.py`.
|
||||||
|
|
||||||
|
**Key classes:**
|
||||||
|
- `BaseCommand`: Base class for all commands with HTMX integration
|
||||||
|
- `Command`: Standard command that executes a Python callable
|
||||||
|
- `LambdaCommand`: Inline command for simple operations
|
||||||
|
- `CommandsManager`: Global registry for command execution
|
||||||
|
|
||||||
|
**How commands work:**
|
||||||
|
1. Create command with action: `cmd = Command("name", "description", callable)`
|
||||||
|
2. Command auto-registers with `CommandsManager`
|
||||||
|
3. `cmd.get_htmx_params()` generates HTMX attributes (`hx-post`, `hx-vals`)
|
||||||
|
4. HTMX posts to `/myfasthtml/commands` route with `c_id`
|
||||||
|
5. `CommandsManager` routes to correct command's `execute()` method
|
||||||
|
|
||||||
|
**Command customization:**
|
||||||
|
```python
|
||||||
|
# Change HTMX target and swap
|
||||||
|
cmd.htmx(target="#result", swap="innerHTML")
|
||||||
|
|
||||||
|
# Bind to observable data (disables swap by default)
|
||||||
|
cmd.bind(data_object)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core System: Bindings
|
||||||
|
|
||||||
|
Bidirectional data binding system connects UI components with Python data objects. Located in `src/myfasthtml/core/bindings.py`.
|
||||||
|
|
||||||
|
**Key concepts:**
|
||||||
|
- **Observable objects**: Use `make_observable()` from myutils to enable change detection
|
||||||
|
- **Three-phase lifecycle**: Create → Activate (bind_ft) → Deactivate
|
||||||
|
- **Detection modes**: How changes are detected (ValueChange, AttributePresence, SelectValueChange)
|
||||||
|
- **Update modes**: How UI updates (ValueChange, AttributePresence, SelectValueChange)
|
||||||
|
- **Data converters**: Transform data between UI and Python representations
|
||||||
|
|
||||||
|
**Binding flow:**
|
||||||
|
1. User changes input → HTMX posts to `/myfasthtml/bindings`
|
||||||
|
2. `Binding.update()` receives form data, updates observable object
|
||||||
|
3. Observable triggers change event → `Binding.notify()`
|
||||||
|
4. All bound UI elements update via HTMX swap-oob
|
||||||
|
|
||||||
|
**Helper usage:**
|
||||||
|
```python
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
|
||||||
|
# Bind input and label to same data
|
||||||
|
input_elt = Input(name="field")
|
||||||
|
label_elt = Label()
|
||||||
|
|
||||||
|
mk.manage_binding(input_elt, Binding(data, "attr"))
|
||||||
|
mk.manage_binding(label_elt, Binding(data, "attr"))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important binding notes:**
|
||||||
|
- Elements MUST have a `name` attribute to trigger updates
|
||||||
|
- Multiple elements can bind to same data attribute
|
||||||
|
- First binding call uses `init_binding=True` to set initial value
|
||||||
|
- Bindings route through `/myfasthtml/bindings` endpoint
|
||||||
|
|
||||||
|
### Core System: Instances
|
||||||
|
|
||||||
|
Session-scoped instance management system. Located in `src/myfasthtml/core/instances.py`.
|
||||||
|
|
||||||
|
**Key classes:**
|
||||||
|
- `BaseInstance`: Base for all managed instances
|
||||||
|
- `SingleInstance`: One instance per parent per session
|
||||||
|
- `UniqueInstance`: One instance ever per session (singleton-like)
|
||||||
|
- `RootInstance`: Top-level singleton for application
|
||||||
|
- `InstancesManager`: Global registry with session-based isolation
|
||||||
|
|
||||||
|
**Instance creation pattern:**
|
||||||
|
```python
|
||||||
|
# __new__ checks registry before creating
|
||||||
|
# If instance exists with same (session_id, _id), returns existing
|
||||||
|
instance = MyInstance(parent, session, _id="optional")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Automatic ID generation:**
|
||||||
|
- SingleInstance: `snake_case_class_name`
|
||||||
|
- UniqueInstance: `snake_case_class_name`
|
||||||
|
- Regular BaseInstance: `parent_prefix-uuid`
|
||||||
|
|
||||||
|
### Application Setup
|
||||||
|
|
||||||
|
**Main entry point**: `create_app()` in `src/myfasthtml/myfastapp.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.myfastapp import create_app
|
||||||
|
|
||||||
|
app, rt = create_app(
|
||||||
|
daisyui=True, # Include DaisyUI CSS
|
||||||
|
vis=True, # Include vis-network.js
|
||||||
|
protect_routes=True, # Enable auth beforeware
|
||||||
|
mount_auth_app=False, # Mount auth routes
|
||||||
|
base_url=None # Base URL for auth
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**What create_app does:**
|
||||||
|
1. Adds MyFastHtml CSS/JS assets via custom static route
|
||||||
|
2. Optionally adds DaisyUI 5 + Tailwind CSS 4
|
||||||
|
3. Optionally adds vis-network.js
|
||||||
|
4. Mounts `/myfasthtml` app for commands and bindings routes
|
||||||
|
5. Optionally sets up auth routes and beforeware
|
||||||
|
6. Creates AuthProxy instance if auth enabled
|
||||||
|
|
||||||
|
### Authentication System
|
||||||
|
|
||||||
|
Located in `src/myfasthtml/auth/`. Integrates with FastAPI backend (myauth package).
|
||||||
|
|
||||||
|
**Key components:**
|
||||||
|
- `auth/utils.py`: JWT helpers, beforeware for route protection
|
||||||
|
- `auth/routes.py`: Login, register, logout routes
|
||||||
|
- `auth/pages/`: LoginPage, RegisterPage, WelcomePage components
|
||||||
|
|
||||||
|
**How auth works:**
|
||||||
|
1. Beforeware checks `access_token` in session before each route
|
||||||
|
2. Auto-refreshes token if < 5 minutes to expiry
|
||||||
|
3. Redirects to `/login` if token invalid/missing
|
||||||
|
4. Protected routes receive `auth` parameter with user info
|
||||||
|
|
||||||
|
**Session structure:**
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
'access_token': 'jwt_token',
|
||||||
|
'refresh_token': 'refresh_token',
|
||||||
|
'user_info': {
|
||||||
|
'email': 'user@example.com',
|
||||||
|
'username': 'user',
|
||||||
|
'roles': ['admin'],
|
||||||
|
'id': 'uuid',
|
||||||
|
'created_at': 'timestamp',
|
||||||
|
'updated_at': 'timestamp'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/myfasthtml/
|
||||||
|
├── myfastapp.py # Main app factory (create_app)
|
||||||
|
├── core/
|
||||||
|
│ ├── commands.py # Command system
|
||||||
|
│ ├── bindings.py # Binding system
|
||||||
|
│ ├── instances.py # Instance management
|
||||||
|
│ ├── utils.py # Utilities, routes app
|
||||||
|
│ ├── constants.py # Routes, constants
|
||||||
|
│ ├── dbmanager.py # Database helpers
|
||||||
|
│ ├── AuthProxy.py # Auth proxy instance
|
||||||
|
│ └── network_utils.py # Network utilities
|
||||||
|
├── controls/
|
||||||
|
│ ├── helpers.py # mk class with UI helpers
|
||||||
|
│ ├── BaseCommands.py # Base command implementations
|
||||||
|
│ ├── Search.py # Search control
|
||||||
|
│ └── Keyboard.py # Keyboard shortcuts
|
||||||
|
├── auth/
|
||||||
|
│ ├── utils.py # JWT, beforeware
|
||||||
|
│ ├── routes.py # Auth routes
|
||||||
|
│ └── pages/ # Login, Register, Welcome pages
|
||||||
|
├── icons/ # Icon libraries (fluent, material, etc.)
|
||||||
|
├── assets/ # CSS/JS files
|
||||||
|
└── test/ # Test utilities
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── core/ # Core system tests
|
||||||
|
├── testclient/ # TestClient and TestableElement tests
|
||||||
|
├── auth/ # Authentication tests
|
||||||
|
├── controls/ # Control tests
|
||||||
|
└── html/ # HTML component tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing System
|
||||||
|
|
||||||
|
**Custom test client**: `myfasthtml.test.TestClient` extends FastHTML test client
|
||||||
|
|
||||||
|
**Key features:**
|
||||||
|
- `user.open(path)`: Navigate to route
|
||||||
|
- `user.find_element(selector)`: Find element by CSS selector
|
||||||
|
- `user.should_see(text)`: Assert text in response
|
||||||
|
- Returns `TestableElement` objects with component-specific methods
|
||||||
|
|
||||||
|
**Testable element types:**
|
||||||
|
- `TestableInput`: `.send(value)`
|
||||||
|
- `TestableCheckbox`: `.check()`, `.uncheck()`, `.toggle()`
|
||||||
|
- `TestableTextarea`: `.send(value)`, `.append(text)`, `.clear()`
|
||||||
|
- `TestableSelect`: `.select(value)`, `.select_by_text(text)`, `.deselect(value)`
|
||||||
|
- `TestableRange`: `.set(value)`, `.increase()`, `.decrease()`
|
||||||
|
- `TestableRadio`: `.select()`
|
||||||
|
- `TestableButton`: `.click()`
|
||||||
|
- `TestableDatalist`: `.send(value)`, `.select_suggestion(value)`
|
||||||
|
|
||||||
|
**Test pattern:**
|
||||||
|
```python
|
||||||
|
def test_component(user, rt):
|
||||||
|
@rt("/")
|
||||||
|
def index():
|
||||||
|
data = Data("initial")
|
||||||
|
component = Component(name="field")
|
||||||
|
label = Label()
|
||||||
|
|
||||||
|
mk.manage_binding(component, Binding(data))
|
||||||
|
mk.manage_binding(label, Binding(data))
|
||||||
|
|
||||||
|
return component, label
|
||||||
|
|
||||||
|
user.open("/")
|
||||||
|
user.should_see("initial")
|
||||||
|
|
||||||
|
elem = user.find_element("selector")
|
||||||
|
elem.method("new_value")
|
||||||
|
user.should_see("new_value")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Patterns
|
||||||
|
|
||||||
|
### Creating interactive buttons with commands
|
||||||
|
```python
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
|
||||||
|
def action():
|
||||||
|
return "Result"
|
||||||
|
|
||||||
|
cmd = Command("action", "Description", action)
|
||||||
|
button = mk.button("Click", command=cmd)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bidirectional binding
|
||||||
|
```python
|
||||||
|
from myutils.observable import make_observable
|
||||||
|
from myfasthtml.core.bindings import Binding
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
|
||||||
|
data = make_observable(Data("value"))
|
||||||
|
input_elt = Input(name="field")
|
||||||
|
label_elt = Label()
|
||||||
|
|
||||||
|
mk.manage_binding(input_elt, Binding(data, "value"))
|
||||||
|
mk.manage_binding(label_elt, Binding(data, "value"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using helpers (mk class)
|
||||||
|
```python
|
||||||
|
# Button with command
|
||||||
|
mk.button("Text", command=cmd, cls="btn-primary")
|
||||||
|
|
||||||
|
# Icon with command
|
||||||
|
mk.icon(icon_svg, size=20, command=cmd)
|
||||||
|
|
||||||
|
# Label with icon
|
||||||
|
mk.label("Text", icon=icon_svg, size="sm")
|
||||||
|
|
||||||
|
# Generic wrapper
|
||||||
|
mk.mk(element, command=cmd, binding=binding)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Gotchas
|
||||||
|
|
||||||
|
1. **Bindings require `name` attribute**: Without it, form data won't include the field
|
||||||
|
2. **Commands auto-register**: Don't manually register with CommandsManager
|
||||||
|
3. **Instances use __new__ caching**: Same (session, id) returns existing instance
|
||||||
|
4. **First binding needs init**: Use `init_binding=True` to set initial value
|
||||||
|
5. **Observable required for bindings**: Use `make_observable()` from myutils
|
||||||
|
6. **Auth routes need base_url**: Pass `base_url` to `create_app()` for proper auth API calls
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
**Core:**
|
||||||
|
- python-fasthtml: Web framework
|
||||||
|
- myauth: Authentication backend
|
||||||
|
- mydbengine: Database abstraction
|
||||||
|
- myutils: Observable pattern, utilities
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- DaisyUI 5: Component library
|
||||||
|
- Tailwind CSS 4: Styling
|
||||||
|
- vis-network: Network visualization
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
- pytest: Testing framework
|
||||||
|
- httpx: HTTP client for tests
|
||||||
|
- python-dotenv: Environment variables
|
||||||
1
Makefile
1
Makefile
@@ -18,6 +18,7 @@ clean-tests:
|
|||||||
rm -rf .sesskey
|
rm -rf .sesskey
|
||||||
rm -rf tests/.sesskey
|
rm -rf tests/.sesskey
|
||||||
rm -rf tests/*.db
|
rm -rf tests/*.db
|
||||||
|
rm -rf tests/.myFastHtmlDb
|
||||||
|
|
||||||
# Alias to clean everything
|
# Alias to clean everything
|
||||||
clean: clean-build clean-tests
|
clean: clean-build clean-tests
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ All other `hx-*` attributes are supported and will be converted to the appropria
|
|||||||
The library automatically adds these parameters to every request:
|
The library automatically adds these parameters to every request:
|
||||||
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
|
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
|
||||||
- `has_focus` - Boolean indicating if the element had focus
|
- `has_focus` - Boolean indicating if the element had focus
|
||||||
|
- `is_inside` - Boolean indicating if the focus is inside the element (element itself or any child)
|
||||||
|
|
||||||
Example final request:
|
Example final request:
|
||||||
```javascript
|
```javascript
|
||||||
@@ -196,7 +197,8 @@ htmx.ajax('POST', '/save-url', {
|
|||||||
values: {
|
values: {
|
||||||
extra: "data", // from hx-vals
|
extra: "data", // from hx-vals
|
||||||
combination: "Ctrl+S", // automatic
|
combination: "Ctrl+S", // automatic
|
||||||
has_focus: true // automatic
|
has_focus: true, // automatic
|
||||||
|
is_inside: true // automatic
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
@@ -262,6 +264,55 @@ f"add_keyboard_support('modal', '{json.dumps(modal_combinations)}')"
|
|||||||
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
|
f"add_keyboard_support('editor', '{json.dumps(editor_combinations)}')"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Removing Keyboard Support
|
||||||
|
|
||||||
|
When you no longer need keyboard support for an element:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Remove keyboard support
|
||||||
|
f"remove_keyboard_support('{element_id}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior**:
|
||||||
|
- Removes the element from the keyboard registry
|
||||||
|
- If this was the last element, automatically detaches global event listeners
|
||||||
|
- Cleans up all associated state (timeouts, snapshots, etc.)
|
||||||
|
- Other elements continue to work normally
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```javascript
|
||||||
|
// Add support
|
||||||
|
add_keyboard_support('modal', '{"esc": {"hx-post": "/close"}}');
|
||||||
|
|
||||||
|
// Later, remove support
|
||||||
|
remove_keyboard_support('modal');
|
||||||
|
// If no other elements remain, keyboard listeners are completely removed
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### add_keyboard_support(elementId, combinationsJson)
|
||||||
|
|
||||||
|
Adds keyboard support to an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
- `combinationsJson` (string): JSON string of combinations with HTMX configs
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
### remove_keyboard_support(elementId)
|
||||||
|
|
||||||
|
Removes keyboard support from an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
**Side effects**:
|
||||||
|
- If last element: detaches global event listeners and cleans up all state
|
||||||
|
|
||||||
## Technical Details
|
## Technical Details
|
||||||
|
|
||||||
### Trie-based Matching
|
### Trie-based Matching
|
||||||
@@ -277,7 +328,7 @@ The library uses a prefix tree (trie) data structure:
|
|||||||
Configuration objects are mapped to htmx.ajax() calls:
|
Configuration objects are mapped to htmx.ajax() calls:
|
||||||
- `hx-*` attributes are converted to camelCase parameters
|
- `hx-*` attributes are converted to camelCase parameters
|
||||||
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
|
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
|
||||||
- `combination` and `has_focus` are automatically added to values
|
- `combination`, `has_focus`, and `is_inside` are automatically added to values
|
||||||
- All standard HTMX options are supported
|
- All standard HTMX options are supported
|
||||||
|
|
||||||
### Key Normalization
|
### Key Normalization
|
||||||
583
docs/Layout.md
Normal file
583
docs/Layout.md
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
# Layout Component
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The Layout component provides a complete application structure with fixed header and footer, a scrollable main content
|
||||||
|
area, and optional collapsible side drawers. It's designed to be the foundation of your FastHTML application's UI.
|
||||||
|
|
||||||
|
**Key features:**
|
||||||
|
|
||||||
|
- Fixed header and footer that stay visible while scrolling
|
||||||
|
- Collapsible left and right drawers for navigation, tools, or auxiliary content
|
||||||
|
- Resizable drawers with drag handles
|
||||||
|
- Automatic state persistence per session
|
||||||
|
- Single instance per session (singleton pattern)
|
||||||
|
|
||||||
|
**Common use cases:**
|
||||||
|
|
||||||
|
- Application with navigation sidebar
|
||||||
|
- Dashboard with tools panel
|
||||||
|
- Admin interface with settings drawer
|
||||||
|
- Documentation site with table of contents
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Here's a minimal example showing an application with a navigation sidebar:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.Layout import Layout
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
|
||||||
|
# Create the layout instance
|
||||||
|
layout = Layout(parent=root_instance, app_name="My App")
|
||||||
|
|
||||||
|
# Add navigation items to the left drawer
|
||||||
|
layout.left_drawer.add(
|
||||||
|
mk.mk(Div("Home"), command=Command(...))
|
||||||
|
)
|
||||||
|
layout.left_drawer.add(
|
||||||
|
mk.mk(Div("About"), command=Command(...))
|
||||||
|
)
|
||||||
|
layout.left_drawer.add(
|
||||||
|
mk.mk(Div("Contact"), command=Command(...))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set the main content
|
||||||
|
layout.set_main(
|
||||||
|
Div(
|
||||||
|
H1("Welcome"),
|
||||||
|
P("This is the main content area")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Render the layout
|
||||||
|
return layout
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a complete application layout with:
|
||||||
|
|
||||||
|
- A header displaying the app name and drawer toggle button
|
||||||
|
- A collapsible left drawer with interactive navigation items
|
||||||
|
- A main content area that updates when navigation items are clicked
|
||||||
|
- An empty footer
|
||||||
|
|
||||||
|
**Note:** Navigation items use commands to update the main content area without page reload. See the Commands section
|
||||||
|
below for details.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Creating a Layout
|
||||||
|
|
||||||
|
The Layout component is a `SingleInstance`, meaning there's only one instance per session. Create it by providing a
|
||||||
|
parent instance and an application name:
|
||||||
|
|
||||||
|
```python
|
||||||
|
layout = Layout(parent=root_instance, app_name="My Application")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content Zones
|
||||||
|
|
||||||
|
The Layout provides six content zones where you can add components:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ Header │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ header_left │ │ header_right │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ │
|
||||||
|
├─────────┬────────────────────────────────────┬───────────┤
|
||||||
|
│ │ │ │
|
||||||
|
│ left │ │ right │
|
||||||
|
│ drawer │ Main Content │ drawer │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ │ │
|
||||||
|
├─────────┴────────────────────────────────────┴───────────┤
|
||||||
|
│ Footer │
|
||||||
|
│ ┌─────────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ footer_left │ │ footer_right │ │
|
||||||
|
│ └─────────────────┘ └─────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zone details:**
|
||||||
|
|
||||||
|
| Zone | Typical Use |
|
||||||
|
|----------------|-----------------------------------------------|
|
||||||
|
| `header_left` | App logo, menu button, breadcrumbs |
|
||||||
|
| `header_right` | User profile, notifications, settings |
|
||||||
|
| `left_drawer` | Navigation menu, tree view, filters |
|
||||||
|
| `right_drawer` | Tools panel, properties inspector, debug info |
|
||||||
|
| `footer_left` | Copyright, legal links, version |
|
||||||
|
| `footer_right` | Status indicators, connection state |
|
||||||
|
|
||||||
|
### Adding Content to Zones
|
||||||
|
|
||||||
|
Use the `.add()` method to add components to any zone:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Header
|
||||||
|
layout.header_left.add(Div("Logo"))
|
||||||
|
layout.header_right.add(Div("User: Admin"))
|
||||||
|
|
||||||
|
# Drawers
|
||||||
|
layout.left_drawer.add(Div("Navigation"))
|
||||||
|
layout.right_drawer.add(Div("Tools"))
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
layout.footer_left.add(Div("© 2024 My App"))
|
||||||
|
layout.footer_right.add(Div("v1.0.0"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setting Main Content
|
||||||
|
|
||||||
|
The main content area displays your page content and can be updated dynamically:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Set initial content
|
||||||
|
layout.set_main(
|
||||||
|
Div(
|
||||||
|
H1("Dashboard"),
|
||||||
|
P("Welcome to your dashboard")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update later (typically via commands)
|
||||||
|
layout.set_main(
|
||||||
|
Div(
|
||||||
|
H1("Settings"),
|
||||||
|
P("Configure your preferences")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Controlling Drawers
|
||||||
|
|
||||||
|
By default, both drawers are visible. The drawer state is managed automatically:
|
||||||
|
|
||||||
|
- Users can toggle drawers using the icon buttons in the header
|
||||||
|
- Users can resize drawers by dragging the handle
|
||||||
|
- Drawer state persists within the session
|
||||||
|
|
||||||
|
The initial drawer widths are:
|
||||||
|
|
||||||
|
- Left drawer: 250px
|
||||||
|
- Right drawer: 250px
|
||||||
|
|
||||||
|
These can be adjusted by users and the state is preserved automatically.
|
||||||
|
|
||||||
|
## Content System
|
||||||
|
|
||||||
|
### Understanding Groups
|
||||||
|
|
||||||
|
Each content zone (header_left, header_right, drawers, footer) supports **groups** to organize related items. Groups are
|
||||||
|
separated visually by dividers and can have optional labels.
|
||||||
|
|
||||||
|
### Adding Content to Groups
|
||||||
|
|
||||||
|
When adding content, you can optionally specify a group name:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Add items to different groups in the left drawer
|
||||||
|
layout.left_drawer.add(Div("Dashboard"), group="main")
|
||||||
|
layout.left_drawer.add(Div("Analytics"), group="main")
|
||||||
|
layout.left_drawer.add(Div("Settings"), group="preferences")
|
||||||
|
layout.left_drawer.add(Div("Profile"), group="preferences")
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates two groups:
|
||||||
|
|
||||||
|
- **main**: Dashboard, Analytics
|
||||||
|
- **preferences**: Settings, Profile
|
||||||
|
|
||||||
|
A visual divider automatically appears between groups.
|
||||||
|
|
||||||
|
### Custom Group Labels
|
||||||
|
|
||||||
|
You can provide a custom FastHTML element to display as the group header:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Add a styled group header
|
||||||
|
layout.left_drawer.add_group(
|
||||||
|
"Navigation",
|
||||||
|
group_ft=Div("MAIN MENU", cls="font-bold text-sm opacity-60 px-2 py-1")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then add items to this group
|
||||||
|
layout.left_drawer.add(Div("Home"), group="Navigation")
|
||||||
|
layout.left_drawer.add(Div("About"), group="Navigation")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ungrouped Content
|
||||||
|
|
||||||
|
If you don't specify a group, content is added to the default (`None`) group:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# These items are in the default group
|
||||||
|
layout.left_drawer.add(Div("Quick Action 1"))
|
||||||
|
layout.left_drawer.add(Div("Quick Action 2"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Preventing Duplicates
|
||||||
|
|
||||||
|
The Content system automatically prevents adding duplicate items based on their `id` attribute:
|
||||||
|
|
||||||
|
```python
|
||||||
|
item = Div("Unique Item", id="my-item")
|
||||||
|
layout.left_drawer.add(item)
|
||||||
|
layout.left_drawer.add(item) # Ignored - already added
|
||||||
|
```
|
||||||
|
|
||||||
|
### Group Rendering Options
|
||||||
|
|
||||||
|
Groups render differently depending on the zone:
|
||||||
|
|
||||||
|
**In drawers** (vertical layout):
|
||||||
|
|
||||||
|
- Groups stack vertically
|
||||||
|
- Dividers are horizontal lines
|
||||||
|
- Group labels appear above their content
|
||||||
|
|
||||||
|
**In header/footer** (horizontal layout):
|
||||||
|
|
||||||
|
- Groups arrange side-by-side
|
||||||
|
- Dividers are vertical lines
|
||||||
|
- Group labels are typically hidden
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Resizable Drawers
|
||||||
|
|
||||||
|
Both drawers can be resized by users via drag handles:
|
||||||
|
|
||||||
|
- **Drag handle location**:
|
||||||
|
- Left drawer: Right edge
|
||||||
|
- Right drawer: Left edge
|
||||||
|
- **Width constraints**: 150px (minimum) to 600px (maximum)
|
||||||
|
- **Persistence**: Resized width is automatically saved in the session state
|
||||||
|
|
||||||
|
Users can drag the handle to adjust drawer width. The new width is preserved throughout their session.
|
||||||
|
|
||||||
|
### Programmatic Drawer Control
|
||||||
|
|
||||||
|
You can control drawers programmatically using commands:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Toggle drawer visibility
|
||||||
|
toggle_left = layout.commands.toggle_drawer("left")
|
||||||
|
toggle_right = layout.commands.toggle_drawer("right")
|
||||||
|
|
||||||
|
# Update drawer width
|
||||||
|
update_left_width = layout.commands.update_drawer_width("left", width=300)
|
||||||
|
update_right_width = layout.commands.update_drawer_width("right", width=350)
|
||||||
|
```
|
||||||
|
|
||||||
|
These commands are typically used with buttons or other interactive elements:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Add a button to toggle the right drawer
|
||||||
|
button = mk.button("Toggle Tools", command=layout.commands.toggle_drawer("right"))
|
||||||
|
layout.header_right.add(button)
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Persistence
|
||||||
|
|
||||||
|
The Layout automatically persists its state within the user's session:
|
||||||
|
|
||||||
|
| State Property | Description | Default |
|
||||||
|
|----------------------|---------------------------------|---------|
|
||||||
|
| `left_drawer_open` | Whether left drawer is visible | `True` |
|
||||||
|
| `right_drawer_open` | Whether right drawer is visible | `True` |
|
||||||
|
| `left_drawer_width` | Left drawer width in pixels | `250` |
|
||||||
|
| `right_drawer_width` | Right drawer width in pixels | `250` |
|
||||||
|
|
||||||
|
State changes (toggle, resize) are automatically saved and restored within the session.
|
||||||
|
|
||||||
|
### Dynamic Content Updates
|
||||||
|
|
||||||
|
Content zones can be updated dynamically during the session:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Initial setup
|
||||||
|
layout.left_drawer.add(Div("Item 1"))
|
||||||
|
|
||||||
|
|
||||||
|
# Later, add more items (e.g., in a command handler)
|
||||||
|
def add_dynamic_content():
|
||||||
|
layout.left_drawer.add(Div("New Item"), group="dynamic")
|
||||||
|
return layout.left_drawer # Return updated drawer for HTMX swap
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: When updating content dynamically, you typically return the updated zone to trigger an HTMX swap.
|
||||||
|
|
||||||
|
### CSS Customization
|
||||||
|
|
||||||
|
The Layout uses CSS classes that you can customize:
|
||||||
|
|
||||||
|
| Class | Element |
|
||||||
|
|----------------------------|----------------------------------|
|
||||||
|
| `mf-layout` | Root layout container |
|
||||||
|
| `mf-layout-header` | Header section |
|
||||||
|
| `mf-layout-footer` | Footer section |
|
||||||
|
| `mf-layout-main` | Main content area |
|
||||||
|
| `mf-layout-drawer` | Drawer container |
|
||||||
|
| `mf-layout-left-drawer` | Left drawer specifically |
|
||||||
|
| `mf-layout-right-drawer` | Right drawer specifically |
|
||||||
|
| `mf-layout-drawer-content` | Scrollable content within drawer |
|
||||||
|
| `mf-resizer` | Resize handle |
|
||||||
|
| `mf-layout-group` | Content group wrapper |
|
||||||
|
|
||||||
|
You can override these classes in your custom CSS to change colors, spacing, or behavior.
|
||||||
|
|
||||||
|
### User Profile Integration
|
||||||
|
|
||||||
|
The Layout automatically includes a UserProfile component in the header right area. This component handles user
|
||||||
|
authentication display and logout functionality when auth is enabled.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: Dashboard with Navigation Sidebar
|
||||||
|
|
||||||
|
A typical dashboard application with a navigation menu in the left drawer:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.Layout import Layout
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
|
||||||
|
# Create layout
|
||||||
|
layout = Layout(parent=root_instance, app_name="Analytics Dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
# Navigation menu in left drawer
|
||||||
|
def show_dashboard():
|
||||||
|
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
|
||||||
|
return layout._mk_main()
|
||||||
|
|
||||||
|
|
||||||
|
def show_reports():
|
||||||
|
layout.set_main(Div(H1("Reports"), P("Detailed analytics reports")))
|
||||||
|
return layout._mk_main()
|
||||||
|
|
||||||
|
|
||||||
|
def show_settings():
|
||||||
|
layout.set_main(Div(H1("Settings"), P("Configure your preferences")))
|
||||||
|
return layout._mk_main()
|
||||||
|
|
||||||
|
|
||||||
|
# Add navigation items with groups
|
||||||
|
layout.left_drawer.add_group("main", group_ft=Div("MENU", cls="font-bold text-xs px-2 opacity-60"))
|
||||||
|
layout.left_drawer.add(mk.mk(Div("Dashboard"), command=Command("nav_dash", "Show dashboard", show_dashboard)),
|
||||||
|
group="main")
|
||||||
|
layout.left_drawer.add(mk.mk(Div("Reports"), command=Command("nav_reports", "Show reports", show_reports)),
|
||||||
|
group="main")
|
||||||
|
|
||||||
|
layout.left_drawer.add_group("config", group_ft=Div("CONFIGURATION", cls="font-bold text-xs px-2 opacity-60"))
|
||||||
|
layout.left_drawer.add(mk.mk(Div("Settings"), command=Command("nav_settings", "Show settings", show_settings)),
|
||||||
|
group="config")
|
||||||
|
|
||||||
|
# Header content
|
||||||
|
layout.header_left.add(Div("📊 Analytics", cls="font-bold"))
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
layout.footer_left.add(Div("© 2024 Analytics Co."))
|
||||||
|
layout.footer_right.add(Div("v1.0.0"))
|
||||||
|
|
||||||
|
# Set initial main content
|
||||||
|
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Development Tool with Debug Panel
|
||||||
|
|
||||||
|
An application with development tools in the right drawer:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.Layout import Layout
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
|
||||||
|
# Create layout
|
||||||
|
layout = Layout(parent=root_instance, app_name="Dev Tools")
|
||||||
|
|
||||||
|
# Main content: code editor
|
||||||
|
layout.set_main(
|
||||||
|
Div(
|
||||||
|
H2("Code Editor"),
|
||||||
|
Textarea("# Write your code here", rows=20, cls="w-full font-mono")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Right drawer: debug and tools
|
||||||
|
layout.right_drawer.add_group("debug", group_ft=Div("DEBUG INFO", cls="font-bold text-xs px-2 opacity-60"))
|
||||||
|
layout.right_drawer.add(Div("Console output here..."), group="debug")
|
||||||
|
layout.right_drawer.add(Div("Variables: x=10, y=20"), group="debug")
|
||||||
|
|
||||||
|
layout.right_drawer.add_group("tools", group_ft=Div("TOOLS", cls="font-bold text-xs px-2 opacity-60"))
|
||||||
|
layout.right_drawer.add(Button("Run Code"), group="tools")
|
||||||
|
layout.right_drawer.add(Button("Clear Console"), group="tools")
|
||||||
|
|
||||||
|
# Header
|
||||||
|
layout.header_left.add(Div("DevTools IDE"))
|
||||||
|
layout.header_right.add(Button("Save"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Minimal Layout (Main Content Only)
|
||||||
|
|
||||||
|
A simple layout without drawers, focusing only on main content:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.Layout import Layout
|
||||||
|
|
||||||
|
# Create layout
|
||||||
|
layout = Layout(parent=root_instance, app_name="Simple Blog")
|
||||||
|
|
||||||
|
# Header
|
||||||
|
layout.header_left.add(Div("My Blog", cls="text-xl font-bold"))
|
||||||
|
layout.header_right.add(A("About", href="/about"))
|
||||||
|
|
||||||
|
# Main content
|
||||||
|
layout.set_main(
|
||||||
|
Article(
|
||||||
|
H1("Welcome to My Blog"),
|
||||||
|
P("This is a simple blog layout without side drawers."),
|
||||||
|
P("The focus is on the content in the center.")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Footer
|
||||||
|
layout.footer_left.add(Div("© 2024 Blog Author"))
|
||||||
|
layout.footer_right.add(A("RSS", href="/rss"))
|
||||||
|
|
||||||
|
# Note: Drawers are present but can be collapsed by users if not needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Dynamic Content Loading
|
||||||
|
|
||||||
|
Loading content dynamically based on user interaction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.Layout import Layout
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
|
||||||
|
layout = Layout(parent=root_instance, app_name="Dynamic App")
|
||||||
|
|
||||||
|
|
||||||
|
# Function that loads content dynamically
|
||||||
|
def load_page(page_name):
|
||||||
|
# Simulate loading different content
|
||||||
|
content = {
|
||||||
|
"home": Div(H1("Home"), P("Welcome to the home page")),
|
||||||
|
"profile": Div(H1("Profile"), P("User profile information")),
|
||||||
|
"settings": Div(H1("Settings"), P("Application settings")),
|
||||||
|
}
|
||||||
|
layout.set_main(content.get(page_name, Div("Page not found")))
|
||||||
|
return layout._mk_main()
|
||||||
|
|
||||||
|
|
||||||
|
# Create navigation commands
|
||||||
|
pages = ["home", "profile", "settings"]
|
||||||
|
for page in pages:
|
||||||
|
cmd = Command(f"load_{page}", f"Load {page} page", load_page, page)
|
||||||
|
layout.left_drawer.add(
|
||||||
|
mk.mk(Div(page.capitalize()), command=cmd)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set initial content
|
||||||
|
layout.set_main(Div(H1("Home"), P("Welcome to the home page")))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Developer Reference
|
||||||
|
|
||||||
|
This section contains technical details for developers working on the Layout component itself.
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
The Layout component maintains the following state properties:
|
||||||
|
|
||||||
|
| Name | Type | Description | Default |
|
||||||
|
|----------------------|---------|----------------------------------|---------|
|
||||||
|
| `left_drawer_open` | boolean | True if the left drawer is open | True |
|
||||||
|
| `right_drawer_open` | boolean | True if the right drawer is open | True |
|
||||||
|
| `left_drawer_width` | integer | Width of the left drawer | 250 |
|
||||||
|
| `right_drawer_width` | integer | Width of the right drawer | 250 |
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
Available commands for programmatic control:
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
|-----------------------------------------|----------------------------------------------------------------------------------------|
|
||||||
|
| `toggle_drawer(side)` | Toggles the drawer on the specified side |
|
||||||
|
| `update_drawer_width(side, width=None)` | Updates the drawer width on the specified side. The width is given by the HTMX request |
|
||||||
|
|
||||||
|
### Public Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|---------------------|-----------------------------|
|
||||||
|
| `set_main(content)` | Sets the main content area |
|
||||||
|
| `render()` | Renders the complete layout |
|
||||||
|
|
||||||
|
### High Level Hierarchical Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Div(id="layout")
|
||||||
|
├── Header
|
||||||
|
│ ├── Div(id="layout_hl")
|
||||||
|
│ │ ├── Icon # Left drawer icon button
|
||||||
|
│ │ └── Div # Left content for the header
|
||||||
|
│ └── Div(id="layout_hr")
|
||||||
|
│ ├── Div # Right content for the header
|
||||||
|
│ └── UserProfile # user profile icon button
|
||||||
|
├── Div # Left Drawer
|
||||||
|
├── Main # Main content
|
||||||
|
├── Div # Right Drawer
|
||||||
|
├── Footer # Footer
|
||||||
|
└── Script # To initialize the resizing
|
||||||
|
```
|
||||||
|
|
||||||
|
### Element IDs
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
|-------------|-------------------------------------|
|
||||||
|
| `layout` | Root layout container (singleton) |
|
||||||
|
| `layout_h` | Header section (not currently used) |
|
||||||
|
| `layout_hl` | Header left side |
|
||||||
|
| `layout_hr` | Header right side |
|
||||||
|
| `layout_f` | Footer section (not currently used) |
|
||||||
|
| `layout_fl` | Footer left side |
|
||||||
|
| `layout_fr` | Footer right side |
|
||||||
|
| `layout_ld` | Left drawer |
|
||||||
|
| `layout_rd` | Right drawer |
|
||||||
|
|
||||||
|
### Internal Methods
|
||||||
|
|
||||||
|
These methods are used internally for rendering:
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|---------------------------|--------------------------------------------------------|
|
||||||
|
| `_mk_header()` | Renders the header component |
|
||||||
|
| `_mk_footer()` | Renders the footer component |
|
||||||
|
| `_mk_main()` | Renders the main content area |
|
||||||
|
| `_mk_left_drawer()` | Renders the left drawer |
|
||||||
|
| `_mk_right_drawer()` | Renders the right drawer |
|
||||||
|
| `_mk_left_drawer_icon()` | Renders the left drawer toggle icon |
|
||||||
|
| `_mk_right_drawer_icon()` | Renders the right drawer toggle icon |
|
||||||
|
| `_mk_content_wrapper()` | Static method to wrap content with groups and dividers |
|
||||||
|
|
||||||
|
### Content Class
|
||||||
|
|
||||||
|
The `Layout.Content` nested class manages content zones:
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|-----------------------------------|----------------------------------------------------------|
|
||||||
|
| `add(content, group=None)` | Adds content to a group, prevents duplicates based on ID |
|
||||||
|
| `add_group(group, group_ft=None)` | Creates a new group with optional custom header element |
|
||||||
|
| `get_content()` | Returns dictionary of groups and their content |
|
||||||
|
| `get_groups()` | Returns list of (group_name, group_ft) tuples |
|
||||||
439
docs/Mouse Support.md
Normal file
439
docs/Mouse Support.md
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
# Mouse Support - Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The mouse support library provides keyboard-like binding capabilities for mouse actions. It supports simple clicks, modified clicks (with Ctrl/Shift/Alt), and sequences of clicks with smart timeout logic.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Supported Mouse Actions
|
||||||
|
|
||||||
|
**Basic Actions**:
|
||||||
|
- `click` - Left click
|
||||||
|
- `right_click` (or `rclick`) - Right click (contextmenu)
|
||||||
|
|
||||||
|
**Modified Actions**:
|
||||||
|
- `ctrl+click` (or `ctrl+rclick`) - Ctrl+Click (or Cmd+Click on Mac)
|
||||||
|
- `shift+click` (or `shift+rclick`) - Shift+Click
|
||||||
|
- `alt+click` (or `alt+rclick`) - Alt+Click
|
||||||
|
- `ctrl+shift+click` - Multiple modifiers
|
||||||
|
- Any combination of modifiers
|
||||||
|
|
||||||
|
**Sequences**:
|
||||||
|
- `click right_click` (or `click rclick`) - Click then right-click within 500ms
|
||||||
|
- `click click` - Double click sequence
|
||||||
|
- `ctrl+click click` - Ctrl+click then normal click
|
||||||
|
- Any sequence of actions
|
||||||
|
|
||||||
|
**Note**: `rclick` is an alias for `right_click` and can be used interchangeably.
|
||||||
|
|
||||||
|
### Smart Timeout Logic
|
||||||
|
|
||||||
|
Same as keyboard support:
|
||||||
|
- If **any element** has a longer sequence possible, **all matching elements wait**
|
||||||
|
- Timeout is 500ms between actions
|
||||||
|
- Immediate trigger if no longer sequences exist
|
||||||
|
|
||||||
|
### Multiple Element Support
|
||||||
|
|
||||||
|
Multiple elements can listen to the same mouse action and all will trigger simultaneously.
|
||||||
|
|
||||||
|
## Configuration Format
|
||||||
|
|
||||||
|
Uses HTMX configuration objects (same as keyboard support):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/handle-click",
|
||||||
|
"hx-target": "#result"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/handle-ctrl-click",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
},
|
||||||
|
"rclick": { // Alias for right_click
|
||||||
|
"hx-post": "/context-menu"
|
||||||
|
},
|
||||||
|
"click rclick": { // Can use rclick in sequences too
|
||||||
|
"hx-post": "/sequence-action",
|
||||||
|
"hx-vals": {"type": "sequence"}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_mouse_support('my-element', JSON.stringify(combinations));
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### add_mouse_support(elementId, combinationsJson)
|
||||||
|
|
||||||
|
Adds mouse support to an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
- `combinationsJson` (string): JSON string of combinations with HTMX configs
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```javascript
|
||||||
|
add_mouse_support('button1', JSON.stringify({
|
||||||
|
"click": {"hx-post": "/click"},
|
||||||
|
"ctrl+click": {"hx-post": "/ctrl-click"}
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### remove_mouse_support(elementId)
|
||||||
|
|
||||||
|
Removes mouse support from an element.
|
||||||
|
|
||||||
|
**Parameters**:
|
||||||
|
- `elementId` (string): ID of the HTML element
|
||||||
|
|
||||||
|
**Returns**: void
|
||||||
|
|
||||||
|
**Side effects**:
|
||||||
|
- If last element: detaches global event listeners and cleans up all state
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```javascript
|
||||||
|
remove_mouse_support('button1');
|
||||||
|
```
|
||||||
|
|
||||||
|
## Automatic Parameters
|
||||||
|
|
||||||
|
The library automatically adds these parameters to every HTMX request:
|
||||||
|
- `combination` - The mouse combination that triggered the action (e.g., "ctrl+click")
|
||||||
|
- `has_focus` - Boolean indicating if the element had focus when clicked
|
||||||
|
- `is_inside` - Boolean indicating if the click was inside the element
|
||||||
|
- For `click`: `true` if clicked inside element, `false` if clicked outside
|
||||||
|
- For `right_click`: always `true` (only triggers when clicking on element)
|
||||||
|
- `has_focus` - Boolean indicating if the element had focus when the action triggered
|
||||||
|
- `clicked_inside` - Boolean indicating if the click was inside the element or outside
|
||||||
|
|
||||||
|
### Parameter Details
|
||||||
|
|
||||||
|
**`has_focus`**:
|
||||||
|
- `true` if the registered element currently has focus
|
||||||
|
- `false` otherwise
|
||||||
|
- Useful for knowing if the element was the active element
|
||||||
|
|
||||||
|
**`clicked_inside`**:
|
||||||
|
- For `click` actions: `true` if clicked on/inside the element, `false` if clicked outside
|
||||||
|
- For `right_click` actions: always `true` (since right-click only triggers on the element)
|
||||||
|
- Useful for "click outside to close" logic
|
||||||
|
|
||||||
|
**Example values sent**:
|
||||||
|
```javascript
|
||||||
|
// User clicks inside a modal
|
||||||
|
{
|
||||||
|
combination: "click",
|
||||||
|
has_focus: true,
|
||||||
|
clicked_inside: true
|
||||||
|
}
|
||||||
|
|
||||||
|
// User clicks outside the modal (modal still gets triggered because click is global)
|
||||||
|
{
|
||||||
|
combination: "click",
|
||||||
|
has_focus: false,
|
||||||
|
clicked_inside: false // Perfect for closing the modal!
|
||||||
|
}
|
||||||
|
|
||||||
|
// User right-clicks on an item
|
||||||
|
{
|
||||||
|
combination: "right_click",
|
||||||
|
has_focus: false,
|
||||||
|
clicked_inside: true // Always true for right_click
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Integration
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/item/select"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/item/select-multiple",
|
||||||
|
"hx-vals": json.dumps({"mode": "multi"})
|
||||||
|
},
|
||||||
|
"right_click": {
|
||||||
|
"hx-post": "/item/context-menu",
|
||||||
|
"hx-target": "#context-menu",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sequences
|
||||||
|
|
||||||
|
```python
|
||||||
|
combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/single-click"
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/double-click-sequence"
|
||||||
|
},
|
||||||
|
"click right_click": {
|
||||||
|
"hx-post": "/click-then-right-click"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiple Elements
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Item 1
|
||||||
|
item1_combinations = {
|
||||||
|
"click": {"hx-post": f"/item/1/select"},
|
||||||
|
"ctrl+click": {"hx-post": f"/item/1/toggle"}
|
||||||
|
}
|
||||||
|
f"add_mouse_support('item-1', '{json.dumps(item1_combinations)}')"
|
||||||
|
|
||||||
|
# Item 2
|
||||||
|
item2_combinations = {
|
||||||
|
"click": {"hx-post": f"/item/2/select"},
|
||||||
|
"ctrl+click": {"hx-post": f"/item/2/toggle"}
|
||||||
|
}
|
||||||
|
f"add_mouse_support('item-2', '{json.dumps(item2_combinations)}')"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior Details
|
||||||
|
|
||||||
|
### Click vs Right-Click Behavior
|
||||||
|
|
||||||
|
**IMPORTANT**: The library handles `click` and `right_click` differently:
|
||||||
|
|
||||||
|
**`click` (global detection)**:
|
||||||
|
- Triggers for ALL registered elements, regardless of where you click
|
||||||
|
- Useful for "click outside to close" functionality (modals, dropdowns, popups)
|
||||||
|
- Example: Modal registered with `click` → clicking anywhere on the page triggers the modal's click action
|
||||||
|
|
||||||
|
**`right_click` (element-specific detection)**:
|
||||||
|
- Triggers ONLY when you right-click on (or inside) the registered element
|
||||||
|
- Right-clicking outside the element does nothing and shows browser's context menu
|
||||||
|
- This preserves normal browser behavior while adding custom actions on your elements
|
||||||
|
|
||||||
|
**Example use case**:
|
||||||
|
```javascript
|
||||||
|
// Modal that closes when clicking anywhere
|
||||||
|
add_mouse_support('modal', JSON.stringify({
|
||||||
|
"click": {"hx-post": "/close-modal"} // Triggers even if you click outside modal
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Context menu that only appears on element
|
||||||
|
add_mouse_support('item', JSON.stringify({
|
||||||
|
"right_click": {"hx-post": "/item-menu"} // Only triggers when right-clicking the item
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modifier Keys (Cross-Platform)
|
||||||
|
|
||||||
|
- **Windows/Linux**: `ctrl+click` uses Ctrl key
|
||||||
|
- **Mac**: `ctrl+click` uses Cmd (⌘) key OR Ctrl key
|
||||||
|
- This follows standard web conventions for cross-platform compatibility
|
||||||
|
|
||||||
|
### Input Context Protection
|
||||||
|
|
||||||
|
Mouse actions are **disabled** when clicking in input fields:
|
||||||
|
- `<input>` elements
|
||||||
|
- `<textarea>` elements
|
||||||
|
- Any `contenteditable` element
|
||||||
|
|
||||||
|
This ensures normal text selection and interaction works in forms.
|
||||||
|
|
||||||
|
### Right-Click Menu
|
||||||
|
|
||||||
|
The contextmenu (right-click menu) is prevented with `preventDefault()` when:
|
||||||
|
- A `right_click` action is configured for the element
|
||||||
|
- The element is NOT an input/textarea/contenteditable
|
||||||
|
|
||||||
|
### Event Bubbling
|
||||||
|
|
||||||
|
The library checks if the click target OR any parent element is registered:
|
||||||
|
```html
|
||||||
|
<div id="container">
|
||||||
|
<button>Click me</button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
If `container` is registered, clicking the button will trigger the container's actions.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Using Aliases
|
||||||
|
|
||||||
|
You can use `rclick` instead of `right_click` anywhere:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// These are equivalent
|
||||||
|
const config1 = {
|
||||||
|
"right_click": {"hx-post": "/menu"}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config2 = {
|
||||||
|
"rclick": {"hx-post": "/menu"} // Shorter alias
|
||||||
|
};
|
||||||
|
|
||||||
|
// Works in sequences too
|
||||||
|
const config3 = {
|
||||||
|
"click rclick": {"hx-post": "/sequence"}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Works with modifiers
|
||||||
|
const config4 = {
|
||||||
|
"ctrl+rclick": {"hx-post": "/ctrl-right-click"}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context Menu
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"right_click": {
|
||||||
|
"hx-post": "/show-context-menu",
|
||||||
|
"hx-target": "#menu",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Close Modal/Popup on Click Outside
|
||||||
|
|
||||||
|
Since `click` is detected globally (anywhere on the page), it's perfect for "click outside to close":
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Modal element
|
||||||
|
const modalCombinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/close-modal",
|
||||||
|
"hx-target": "#modal",
|
||||||
|
"hx-swap": "outerHTML"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
add_mouse_support('modal', JSON.stringify(modalCombinations));
|
||||||
|
|
||||||
|
// Now clicking ANYWHERE on the page will trigger the handler
|
||||||
|
// Your backend receives:
|
||||||
|
// - clicked_inside: true (if clicked on modal) → maybe keep it open
|
||||||
|
// - clicked_inside: false (if clicked outside) → close it!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend example (Python/Flask)**:
|
||||||
|
```python
|
||||||
|
@app.route('/close-modal', methods=['POST'])
|
||||||
|
def close_modal():
|
||||||
|
clicked_inside = request.form.get('clicked_inside') == 'true'
|
||||||
|
|
||||||
|
if clicked_inside:
|
||||||
|
# User clicked inside the modal - keep it open
|
||||||
|
return render_template('modal_content.html')
|
||||||
|
else:
|
||||||
|
# User clicked outside - close the modal
|
||||||
|
return '' # Empty response removes the modal
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The click handler on the modal element will trigger for all clicks on the page, not just clicks on the modal itself. Use the `clicked_inside` parameter to determine the appropriate action.
|
||||||
|
|
||||||
|
### Multi-Select List
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/select-item",
|
||||||
|
"hx-vals": {"mode": "single"}
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/select-item",
|
||||||
|
"hx-vals": {"mode": "toggle"}
|
||||||
|
},
|
||||||
|
"shift+click": {
|
||||||
|
"hx-post": "/select-range",
|
||||||
|
"hx-vals": {"mode": "range"}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interactive Canvas/Drawing
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/draw-point"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/draw-special"
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/confirm-action"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drag-and-Drop Alternative
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const combinations = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/select-source"
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/set-destination"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Clicks not detected
|
||||||
|
|
||||||
|
- Verify the element exists and has the correct ID
|
||||||
|
- Check browser console for errors
|
||||||
|
- Ensure HTMX is loaded before mouse_support.js
|
||||||
|
|
||||||
|
### Right-click menu still appears
|
||||||
|
|
||||||
|
- Check if element is in input context (input/textarea)
|
||||||
|
- Verify the combination is configured correctly
|
||||||
|
- Check browser console for configuration errors
|
||||||
|
|
||||||
|
### Sequences not working
|
||||||
|
|
||||||
|
- Ensure clicks happen within 500ms timeout
|
||||||
|
- Check if longer sequences exist (causes waiting)
|
||||||
|
- Verify the combination string format (space-separated)
|
||||||
|
|
||||||
|
## Technical Details
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- **Global listeners** on `document` for `click` and `contextmenu` events
|
||||||
|
- **Tree-based matching** using prefix trees (same as keyboard support)
|
||||||
|
- **Single timeout** for all elements (sequence-based, not element-based)
|
||||||
|
- **Independent from keyboard support** (separate registry and timeouts)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Single event listener regardless of number of elements
|
||||||
|
- O(n) matching where n is sequence length
|
||||||
|
- Efficient memory usage with automatic cleanup
|
||||||
|
|
||||||
|
### Browser Compatibility
|
||||||
|
|
||||||
|
- Modern browsers (ES6+ required)
|
||||||
|
- Chrome, Firefox, Safari, Edge
|
||||||
|
- Requires HTMX library
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Timeout value is the same as keyboard support (500ms) but in separate variable
|
||||||
|
- Can be used independently or alongside keyboard support
|
||||||
|
- Does not interfere with normal mouse behavior in inputs
|
||||||
|
- Element must exist in DOM when `add_mouse_support()` is called
|
||||||
|
- **Alias**: `rclick` can be used interchangeably with `right_click` for shorter syntax
|
||||||
596
docs/TreeView.md
Normal file
596
docs/TreeView.md
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
# TreeView Component
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
The TreeView component provides an interactive hierarchical data visualization with full CRUD operations. It's designed for displaying tree-structured data like file systems, organizational charts, or navigation menus with inline editing capabilities.
|
||||||
|
|
||||||
|
**Key features:**
|
||||||
|
|
||||||
|
- Expand/collapse nodes with visual indicators
|
||||||
|
- Add child and sibling nodes dynamically
|
||||||
|
- Inline rename with keyboard support (ESC to cancel)
|
||||||
|
- Delete nodes (only leaf nodes without children)
|
||||||
|
- Node selection tracking
|
||||||
|
- Persistent state per session
|
||||||
|
- Configurable icons per node type
|
||||||
|
|
||||||
|
**Common use cases:**
|
||||||
|
|
||||||
|
- File/folder browser
|
||||||
|
- Category/subcategory management
|
||||||
|
- Organizational hierarchy viewer
|
||||||
|
- Navigation menu builder
|
||||||
|
- Document outline editor
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Here's a minimal example showing a file system tree:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||||
|
|
||||||
|
# Create TreeView instance
|
||||||
|
tree = TreeView(parent=root_instance, _id="file-tree")
|
||||||
|
|
||||||
|
# Add root folder
|
||||||
|
root = TreeNode(id="root", label="Documents", type="folder")
|
||||||
|
tree.add_node(root)
|
||||||
|
|
||||||
|
# Add some files
|
||||||
|
file1 = TreeNode(id="file1", label="report.pdf", type="file")
|
||||||
|
file2 = TreeNode(id="file2", label="budget.xlsx", type="file")
|
||||||
|
tree.add_node(file1, parent_id="root")
|
||||||
|
tree.add_node(file2, parent_id="root")
|
||||||
|
|
||||||
|
# Expand root to show children
|
||||||
|
tree.expand_all()
|
||||||
|
|
||||||
|
# Render the tree
|
||||||
|
return tree
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates an interactive tree where users can:
|
||||||
|
- Click chevrons to expand/collapse folders
|
||||||
|
- Click labels to select items
|
||||||
|
- Use action buttons (visible on hover) to add, rename, or delete nodes
|
||||||
|
|
||||||
|
**Note:** All interactions use commands and update via HTMX without page reload.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Creating a TreeView
|
||||||
|
|
||||||
|
TreeView is a `MultipleInstance`, allowing multiple trees per session. Create it with a parent instance:
|
||||||
|
|
||||||
|
```python
|
||||||
|
tree = TreeView(parent=root_instance, _id="my-tree")
|
||||||
|
```
|
||||||
|
|
||||||
|
### TreeNode Structure
|
||||||
|
|
||||||
|
Nodes are represented by the `TreeNode` dataclass:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.controls.TreeView import TreeNode
|
||||||
|
|
||||||
|
node = TreeNode(
|
||||||
|
id="unique-id", # Auto-generated UUID if not provided
|
||||||
|
label="Node Label", # Display text
|
||||||
|
type="default", # Type for icon mapping
|
||||||
|
parent=None, # Parent node ID (None for root)
|
||||||
|
children=[] # List of child node IDs
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Nodes
|
||||||
|
|
||||||
|
Add nodes using the `add_node()` method:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Add root node
|
||||||
|
root = TreeNode(id="root", label="Root", type="folder")
|
||||||
|
tree.add_node(root)
|
||||||
|
|
||||||
|
# Add child node
|
||||||
|
child = TreeNode(label="Child 1", type="item")
|
||||||
|
tree.add_node(child, parent_id="root")
|
||||||
|
|
||||||
|
# Add with specific position
|
||||||
|
sibling = TreeNode(label="Child 2", type="item")
|
||||||
|
tree.add_node(sibling, parent_id="root", insert_index=0) # Insert at start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visual Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
TreeView
|
||||||
|
├── Root Node 1
|
||||||
|
│ ├── [>] Child 1-1 # Collapsed node with children
|
||||||
|
│ ├── [ ] Child 1-2 # Leaf node (no children)
|
||||||
|
│ └── [v] Child 1-3 # Expanded node
|
||||||
|
│ ├── [ ] Grandchild
|
||||||
|
│ └── [ ] Grandchild
|
||||||
|
└── Root Node 2
|
||||||
|
└── [>] Child 2-1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Legend:**
|
||||||
|
- `[>]` - Collapsed node (has children)
|
||||||
|
- `[v]` - Expanded node (has children)
|
||||||
|
- `[ ]` - Leaf node (no children)
|
||||||
|
|
||||||
|
### Expanding Nodes
|
||||||
|
|
||||||
|
Control node expansion programmatically:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Expand all nodes with children
|
||||||
|
tree.expand_all()
|
||||||
|
|
||||||
|
# Expand specific nodes by adding to opened list
|
||||||
|
tree._state.opened.append("node-id")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Users can also toggle nodes by clicking the chevron icon.
|
||||||
|
|
||||||
|
## Interactive Features
|
||||||
|
|
||||||
|
### Node Selection
|
||||||
|
|
||||||
|
Users can select nodes by clicking on labels. The selected node is visually highlighted:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Programmatically select a node
|
||||||
|
tree._state.selected = "node-id"
|
||||||
|
|
||||||
|
# Check current selection
|
||||||
|
current = tree._state.selected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding Nodes
|
||||||
|
|
||||||
|
Users can add nodes via action buttons (visible on hover):
|
||||||
|
|
||||||
|
**Add Child:**
|
||||||
|
- Adds a new node as a child of the target node
|
||||||
|
- Automatically expands the parent
|
||||||
|
- Creates node with same type as parent
|
||||||
|
|
||||||
|
**Add Sibling:**
|
||||||
|
- Adds a new node next to the target node (same parent)
|
||||||
|
- Inserts after the target node
|
||||||
|
- Cannot add sibling to root nodes
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Programmatically add child
|
||||||
|
tree._add_child(parent_id="root", new_label="New Child")
|
||||||
|
|
||||||
|
# Programmatically add sibling
|
||||||
|
tree._add_sibling(node_id="child1", new_label="New Sibling")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Renaming Nodes
|
||||||
|
|
||||||
|
Users can rename nodes via the edit button:
|
||||||
|
|
||||||
|
1. Click the edit icon (visible on hover)
|
||||||
|
2. Input field appears with current label
|
||||||
|
3. Press Enter to save (triggers command)
|
||||||
|
4. Press ESC to cancel (keyboard shortcut)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Programmatically start rename
|
||||||
|
tree._start_rename("node-id")
|
||||||
|
|
||||||
|
# Save rename
|
||||||
|
tree._save_rename("node-id", "New Label")
|
||||||
|
|
||||||
|
# Cancel rename
|
||||||
|
tree._cancel_rename()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deleting Nodes
|
||||||
|
|
||||||
|
Users can delete nodes via the delete button:
|
||||||
|
|
||||||
|
**Restrictions:**
|
||||||
|
- Can only delete leaf nodes (no children)
|
||||||
|
- Attempting to delete a node with children raises an error
|
||||||
|
- Deleted node is removed from parent's children list
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Programmatically delete node
|
||||||
|
tree._delete_node("node-id") # Raises ValueError if node has children
|
||||||
|
```
|
||||||
|
|
||||||
|
## Content System
|
||||||
|
|
||||||
|
### Node Types and Icons
|
||||||
|
|
||||||
|
Assign types to nodes for semantic grouping and custom icon display:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Define node types
|
||||||
|
root = TreeNode(label="Project", type="project")
|
||||||
|
folder = TreeNode(label="src", type="folder")
|
||||||
|
file = TreeNode(label="main.py", type="python-file")
|
||||||
|
|
||||||
|
# Configure icons for types
|
||||||
|
tree.set_icon_config({
|
||||||
|
"project": "fluent.folder_open",
|
||||||
|
"folder": "fluent.folder",
|
||||||
|
"python-file": "fluent.document_python"
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Icon configuration is stored in state and persists within the session.
|
||||||
|
|
||||||
|
### Hierarchical Organization
|
||||||
|
|
||||||
|
Nodes automatically maintain parent-child relationships:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get node's children
|
||||||
|
node = tree._state.items["node-id"]
|
||||||
|
child_ids = node.children
|
||||||
|
|
||||||
|
# Get node's parent
|
||||||
|
parent_id = node.parent
|
||||||
|
|
||||||
|
# Navigate tree programmatically
|
||||||
|
for child_id in node.children:
|
||||||
|
child_node = tree._state.items[child_id]
|
||||||
|
print(child_node.label)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Finding Root Nodes
|
||||||
|
|
||||||
|
Root nodes are nodes without a parent:
|
||||||
|
|
||||||
|
```python
|
||||||
|
root_nodes = [
|
||||||
|
node_id for node_id, node in tree._state.items.items()
|
||||||
|
if node.parent is None
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
TreeView includes keyboard support for common operations:
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `ESC` | Cancel rename operation |
|
||||||
|
|
||||||
|
Additional shortcuts can be added via the Keyboard component:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.controls.Keyboard import Keyboard
|
||||||
|
|
||||||
|
tree = TreeView(parent=root_instance)
|
||||||
|
# ESC handler is automatically included for cancel rename
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
TreeView maintains persistent state within the session:
|
||||||
|
|
||||||
|
| State Property | Type | Description |
|
||||||
|
|----------------|------|-------------|
|
||||||
|
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID |
|
||||||
|
| `opened` | `list[str]` | IDs of expanded nodes |
|
||||||
|
| `selected` | `str \| None` | Currently selected node ID |
|
||||||
|
| `editing` | `str \| None` | Node being renamed (if any) |
|
||||||
|
| `icon_config` | `dict[str, str]` | Type-to-icon mapping |
|
||||||
|
|
||||||
|
### Dynamic Updates
|
||||||
|
|
||||||
|
TreeView updates are handled via commands that return the updated tree:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Commands automatically target the tree for HTMX swap
|
||||||
|
cmd = tree.commands.toggle_node("node-id")
|
||||||
|
# When executed, returns updated TreeView with new state
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSS Customization
|
||||||
|
|
||||||
|
TreeView uses CSS classes for styling:
|
||||||
|
|
||||||
|
| Class | Element |
|
||||||
|
|-------|---------|
|
||||||
|
| `mf-treeview` | Root container |
|
||||||
|
| `mf-treenode-container` | Container for node and its children |
|
||||||
|
| `mf-treenode` | Individual node row |
|
||||||
|
| `mf-treenode.selected` | Selected node highlight |
|
||||||
|
| `mf-treenode-label` | Node label text |
|
||||||
|
| `mf-treenode-input` | Input field during rename |
|
||||||
|
| `mf-treenode-actions` | Action buttons container (hover) |
|
||||||
|
|
||||||
|
You can override these classes to customize appearance:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.mf-treenode.selected {
|
||||||
|
background-color: #e0f2fe;
|
||||||
|
border-left: 3px solid #0284c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-treenode-actions {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-treenode:hover .mf-treenode-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Example 1: File System Browser
|
||||||
|
|
||||||
|
A file/folder browser with different node types:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||||
|
|
||||||
|
# Create tree
|
||||||
|
tree = TreeView(parent=root_instance, _id="file-browser")
|
||||||
|
|
||||||
|
# Configure icons
|
||||||
|
tree.set_icon_config({
|
||||||
|
"folder": "fluent.folder",
|
||||||
|
"python": "fluent.document_python",
|
||||||
|
"text": "fluent.document_text"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build file structure
|
||||||
|
root = TreeNode(id="root", label="my-project", type="folder")
|
||||||
|
tree.add_node(root)
|
||||||
|
|
||||||
|
src = TreeNode(id="src", label="src", type="folder")
|
||||||
|
tree.add_node(src, parent_id="root")
|
||||||
|
|
||||||
|
main = TreeNode(label="main.py", type="python")
|
||||||
|
utils = TreeNode(label="utils.py", type="python")
|
||||||
|
tree.add_node(main, parent_id="src")
|
||||||
|
tree.add_node(utils, parent_id="src")
|
||||||
|
|
||||||
|
readme = TreeNode(label="README.md", type="text")
|
||||||
|
tree.add_node(readme, parent_id="root")
|
||||||
|
|
||||||
|
# Expand to show structure
|
||||||
|
tree.expand_all()
|
||||||
|
|
||||||
|
return tree
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Category Management
|
||||||
|
|
||||||
|
Managing product categories with inline editing:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||||
|
|
||||||
|
tree = TreeView(parent=root_instance, _id="categories")
|
||||||
|
|
||||||
|
# Root categories
|
||||||
|
electronics = TreeNode(id="elec", label="Electronics", type="category")
|
||||||
|
tree.add_node(electronics)
|
||||||
|
|
||||||
|
# Subcategories
|
||||||
|
computers = TreeNode(label="Computers", type="subcategory")
|
||||||
|
phones = TreeNode(label="Phones", type="subcategory")
|
||||||
|
tree.add_node(computers, parent_id="elec")
|
||||||
|
tree.add_node(phones, parent_id="elec")
|
||||||
|
|
||||||
|
# Products (leaf nodes)
|
||||||
|
laptop = TreeNode(label="Laptops", type="product")
|
||||||
|
desktop = TreeNode(label="Desktops", type="product")
|
||||||
|
tree.add_node(laptop, parent_id=computers.id)
|
||||||
|
tree.add_node(desktop, parent_id=computers.id)
|
||||||
|
|
||||||
|
tree.expand_all()
|
||||||
|
return tree
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Document Outline Editor
|
||||||
|
|
||||||
|
Building a document outline with headings:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||||
|
|
||||||
|
tree = TreeView(parent=root_instance, _id="outline")
|
||||||
|
|
||||||
|
# Document structure
|
||||||
|
doc = TreeNode(id="doc", label="My Document", type="document")
|
||||||
|
tree.add_node(doc)
|
||||||
|
|
||||||
|
# Chapters
|
||||||
|
ch1 = TreeNode(id="ch1", label="Chapter 1: Introduction", type="heading1")
|
||||||
|
ch2 = TreeNode(id="ch2", label="Chapter 2: Methods", type="heading1")
|
||||||
|
tree.add_node(ch1, parent_id="doc")
|
||||||
|
tree.add_node(ch2, parent_id="doc")
|
||||||
|
|
||||||
|
# Sections
|
||||||
|
sec1_1 = TreeNode(label="1.1 Background", type="heading2")
|
||||||
|
sec1_2 = TreeNode(label="1.2 Objectives", type="heading2")
|
||||||
|
tree.add_node(sec1_1, parent_id="ch1")
|
||||||
|
tree.add_node(sec1_2, parent_id="ch1")
|
||||||
|
|
||||||
|
# Subsections
|
||||||
|
subsec = TreeNode(label="1.1.1 Historical Context", type="heading3")
|
||||||
|
tree.add_node(subsec, parent_id=sec1_1.id)
|
||||||
|
|
||||||
|
tree.expand_all()
|
||||||
|
return tree
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Dynamic Tree with Event Handling
|
||||||
|
|
||||||
|
Responding to tree events with custom logic:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import *
|
||||||
|
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
|
||||||
|
tree = TreeView(parent=root_instance, _id="dynamic-tree")
|
||||||
|
|
||||||
|
# Initial structure
|
||||||
|
root = TreeNode(id="root", label="Tasks", type="folder")
|
||||||
|
tree.add_node(root)
|
||||||
|
|
||||||
|
# Function to handle selection
|
||||||
|
def on_node_selected(node_id):
|
||||||
|
# Custom logic when node is selected
|
||||||
|
node = tree._state.items[node_id]
|
||||||
|
tree._select_node(node_id)
|
||||||
|
|
||||||
|
# Update a detail panel elsewhere in the UI
|
||||||
|
return Div(
|
||||||
|
H3(f"Selected: {node.label}"),
|
||||||
|
P(f"Type: {node.type}"),
|
||||||
|
P(f"Children: {len(node.children)}")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Override select command with custom handler
|
||||||
|
# (In practice, you'd extend the Commands class or use event callbacks)
|
||||||
|
|
||||||
|
tree.expand_all()
|
||||||
|
return tree
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Developer Reference
|
||||||
|
|
||||||
|
This section contains technical details for developers working on the TreeView component itself.
|
||||||
|
|
||||||
|
### State
|
||||||
|
|
||||||
|
The TreeView component maintains the following state properties:
|
||||||
|
|
||||||
|
| Name | Type | Description | Default |
|
||||||
|
|------|------|-------------|---------|
|
||||||
|
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID | `{}` |
|
||||||
|
| `opened` | `list[str]` | Expanded node IDs | `[]` |
|
||||||
|
| `selected` | `str \| None` | Selected node ID | `None` |
|
||||||
|
| `editing` | `str \| None` | Node being renamed | `None` |
|
||||||
|
| `icon_config` | `dict[str, str]` | Type-to-icon mapping | `{}` |
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
Available commands for programmatic control:
|
||||||
|
|
||||||
|
| Name | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `toggle_node(node_id)` | Toggle expand/collapse state |
|
||||||
|
| `add_child(parent_id)` | Add child node to parent |
|
||||||
|
| `add_sibling(node_id)` | Add sibling node after target |
|
||||||
|
| `start_rename(node_id)` | Enter rename mode for node |
|
||||||
|
| `save_rename(node_id)` | Save renamed node label |
|
||||||
|
| `cancel_rename()` | Cancel rename operation |
|
||||||
|
| `delete_node(node_id)` | Delete node (if no children) |
|
||||||
|
| `select_node(node_id)` | Select a node |
|
||||||
|
|
||||||
|
All commands automatically target the TreeView component for HTMX updates.
|
||||||
|
|
||||||
|
### Public Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `add_node(node, parent_id, insert_index)` | Add a node to the tree |
|
||||||
|
| `expand_all()` | Expand all nodes with children |
|
||||||
|
| `set_icon_config(config)` | Configure icons for node types |
|
||||||
|
| `render()` | Render the complete TreeView |
|
||||||
|
|
||||||
|
### TreeNode Dataclass
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class TreeNode:
|
||||||
|
id: str # Unique identifier (auto-generated UUID)
|
||||||
|
label: str = "" # Display text
|
||||||
|
type: str = "default" # Node type for icon mapping
|
||||||
|
parent: Optional[str] = None # Parent node ID
|
||||||
|
children: list[str] = [] # Child node IDs
|
||||||
|
```
|
||||||
|
|
||||||
|
### High Level Hierarchical Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Div(id="treeview", cls="mf-treeview")
|
||||||
|
├── Div(cls="mf-treenode-container", data-node-id="root1")
|
||||||
|
│ ├── Div(cls="mf-treenode")
|
||||||
|
│ │ ├── Icon # Toggle chevron
|
||||||
|
│ │ ├── Span(cls="mf-treenode-label") | Input(cls="mf-treenode-input")
|
||||||
|
│ │ └── Div(cls="mf-treenode-actions")
|
||||||
|
│ │ ├── Icon # Add child
|
||||||
|
│ │ ├── Icon # Rename
|
||||||
|
│ │ └── Icon # Delete
|
||||||
|
│ └── Div(cls="mf-treenode-container") # Child nodes (if expanded)
|
||||||
|
│ └── ...
|
||||||
|
├── Div(cls="mf-treenode-container", data-node-id="root2")
|
||||||
|
│ └── ...
|
||||||
|
└── Keyboard # ESC handler
|
||||||
|
```
|
||||||
|
|
||||||
|
### Element IDs and Attributes
|
||||||
|
|
||||||
|
| Attribute | Element | Description |
|
||||||
|
|-----------|---------|-------------|
|
||||||
|
| `id` | Root Div | TreeView component ID |
|
||||||
|
| `data-node-id` | Node container | Node's unique ID |
|
||||||
|
|
||||||
|
### Internal Methods
|
||||||
|
|
||||||
|
These methods are used internally for rendering and state management:
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `_toggle_node(node_id)` | Toggle expand/collapse state |
|
||||||
|
| `_add_child(parent_id, new_label)` | Add child node implementation |
|
||||||
|
| `_add_sibling(node_id, new_label)` | Add sibling node implementation |
|
||||||
|
| `_start_rename(node_id)` | Enter rename mode |
|
||||||
|
| `_save_rename(node_id, node_label)` | Save renamed node |
|
||||||
|
| `_cancel_rename()` | Cancel rename operation |
|
||||||
|
| `_delete_node(node_id)` | Delete node if no children |
|
||||||
|
| `_select_node(node_id)` | Select a node |
|
||||||
|
| `_render_action_buttons(node_id)` | Render hover action buttons |
|
||||||
|
| `_render_node(node_id, level)` | Recursively render node and children |
|
||||||
|
|
||||||
|
### Commands Class
|
||||||
|
|
||||||
|
The `Commands` nested class provides command factory methods:
|
||||||
|
|
||||||
|
| Method | Returns |
|
||||||
|
|--------|---------|
|
||||||
|
| `toggle_node(node_id)` | Command to toggle node |
|
||||||
|
| `add_child(parent_id)` | Command to add child |
|
||||||
|
| `add_sibling(node_id)` | Command to add sibling |
|
||||||
|
| `start_rename(node_id)` | Command to start rename |
|
||||||
|
| `save_rename(node_id)` | Command to save rename |
|
||||||
|
| `cancel_rename()` | Command to cancel rename |
|
||||||
|
| `delete_node(node_id)` | Command to delete node |
|
||||||
|
| `select_node(node_id)` | Command to select node |
|
||||||
|
|
||||||
|
All commands are automatically configured with HTMX targeting.
|
||||||
|
|
||||||
|
### Integration with Keyboard Component
|
||||||
|
|
||||||
|
TreeView includes a Keyboard component for ESC key handling:
|
||||||
|
|
||||||
|
```python
|
||||||
|
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="-keyboard")
|
||||||
|
```
|
||||||
|
|
||||||
|
This enables users to press ESC to cancel rename operations without clicking.
|
||||||
895
docs/testing_rendered_components.md
Normal file
895
docs/testing_rendered_components.md
Normal file
@@ -0,0 +1,895 @@
|
|||||||
|
# Testing Rendered Components with Matcher
|
||||||
|
|
||||||
|
## Introduction
|
||||||
|
|
||||||
|
When testing FastHTML components, you need to verify that the HTML they generate is correct. Traditional approaches like string comparison are fragile and hard to maintain. The matcher module provides two powerful functions that make component testing simple and reliable:
|
||||||
|
|
||||||
|
- **`matches(actual, expected)`** - Validates that a rendered element matches your expectations
|
||||||
|
- **`find(ft, expected)`** - Searches for specific elements within an HTML tree
|
||||||
|
|
||||||
|
**Key principle**: Test only what matters. The matcher compares only the elements and attributes you explicitly define in your `expected` pattern, ignoring everything else.
|
||||||
|
|
||||||
|
### Why use matcher?
|
||||||
|
|
||||||
|
**Without matcher:**
|
||||||
|
```python
|
||||||
|
# Fragile - breaks if whitespace or attribute order changes
|
||||||
|
assert str(component.render()) == '<div id="x" class="y"><p>Text</p></div>'
|
||||||
|
```
|
||||||
|
|
||||||
|
**With matcher:**
|
||||||
|
```python
|
||||||
|
# Robust - tests only what matters
|
||||||
|
from myfasthtml.test.matcher import matches
|
||||||
|
from fasthtml.common import Div, P
|
||||||
|
|
||||||
|
actual = component.render()
|
||||||
|
expected = Div(P("Text"))
|
||||||
|
matches(actual, expected) # Passes - ignores id and class
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 1: Function Reference
|
||||||
|
|
||||||
|
### matches() - Validate Elements
|
||||||
|
|
||||||
|
#### Purpose
|
||||||
|
|
||||||
|
`matches()` validates that a rendered element structure corresponds exactly to an expected pattern. It's the primary tool for testing component rendering.
|
||||||
|
|
||||||
|
**When to use it:**
|
||||||
|
- Verifying component output in tests
|
||||||
|
- Checking HTML structure
|
||||||
|
- Validating attributes and content
|
||||||
|
|
||||||
|
#### Basic Syntax
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import matches
|
||||||
|
|
||||||
|
matches(actual, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`actual`**: The element to test (usually from `component.render()`)
|
||||||
|
- **`expected`**: The pattern to match against (only include what you want to test)
|
||||||
|
- **Returns**: `True` if matches, raises `AssertionError` if not
|
||||||
|
|
||||||
|
#### Simple Examples
|
||||||
|
|
||||||
|
**Example 1: Basic structure matching**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import matches
|
||||||
|
from fasthtml.common import Div, P
|
||||||
|
|
||||||
|
# The actual rendered element
|
||||||
|
actual = Div(P("Hello World"), id="container", cls="main")
|
||||||
|
|
||||||
|
# Expected pattern - tests only the structure
|
||||||
|
expected = Div(P("Hello World"))
|
||||||
|
|
||||||
|
matches(actual, expected) # ✅ Passes - id and cls are ignored
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 2: Testing specific attributes**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import Button
|
||||||
|
|
||||||
|
actual = Button("Click me",
|
||||||
|
id="btn-1",
|
||||||
|
cls="btn btn-primary",
|
||||||
|
hx_post="/submit",
|
||||||
|
hx_target="#result")
|
||||||
|
|
||||||
|
# Test only the HTMX attribute we care about
|
||||||
|
expected = Button("Click me", hx_post="/submit")
|
||||||
|
|
||||||
|
matches(actual, expected) # ✅ Passes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 3: Nested structure**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import Div, H1, Form, Input, Button
|
||||||
|
|
||||||
|
actual = Div(
|
||||||
|
H1("Registration Form"),
|
||||||
|
Form(
|
||||||
|
Input(name="email", type="email"),
|
||||||
|
Input(name="password", type="password"),
|
||||||
|
Button("Submit", type="submit")
|
||||||
|
),
|
||||||
|
id="page",
|
||||||
|
cls="container"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test only the important parts
|
||||||
|
expected = Div(
|
||||||
|
H1("Registration Form"),
|
||||||
|
Form(
|
||||||
|
Input(name="email"),
|
||||||
|
Button("Submit")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
matches(actual, expected) # ✅ Passes - ignores password field and attributes
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Predicates Reference
|
||||||
|
|
||||||
|
Predicates allow flexible validation when you don't know the exact value but want to validate a pattern.
|
||||||
|
|
||||||
|
##### AttrPredicate - For attribute values
|
||||||
|
|
||||||
|
**Contains(value)** - Attribute contains the value
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import Contains
|
||||||
|
from fasthtml.common import Div
|
||||||
|
|
||||||
|
actual = Div(cls="container main-content active")
|
||||||
|
expected = Div(cls=Contains("main-content"))
|
||||||
|
|
||||||
|
matches(actual, expected) # ✅ Passes
|
||||||
|
```
|
||||||
|
|
||||||
|
**StartsWith(value)** - Attribute starts with the value
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import StartsWith
|
||||||
|
from fasthtml.common import Input
|
||||||
|
|
||||||
|
actual = Input(id="input-username-12345")
|
||||||
|
expected = Input(id=StartsWith("input-username"))
|
||||||
|
|
||||||
|
matches(actual, expected) # ✅ Passes
|
||||||
|
```
|
||||||
|
|
||||||
|
**DoesNotContain(value)** - Attribute does not contain the value
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import DoesNotContain
|
||||||
|
from fasthtml.common import Div
|
||||||
|
|
||||||
|
actual = Div(cls="container active")
|
||||||
|
expected = Div(cls=DoesNotContain("disabled"))
|
||||||
|
|
||||||
|
matches(actual, expected) # ✅ Passes
|
||||||
|
```
|
||||||
|
|
||||||
|
**AnyValue()** - Attribute exists with any non-None value
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import AnyValue
|
||||||
|
from fasthtml.common import Button
|
||||||
|
|
||||||
|
actual = Button("Click", data_action="submit-form", data_id="123")
|
||||||
|
expected = Button("Click", data_action=AnyValue())
|
||||||
|
|
||||||
|
matches(actual, expected) # ✅ Passes - just checks data_action exists
|
||||||
|
```
|
||||||
|
|
||||||
|
##### ChildrenPredicate - For element children
|
||||||
|
|
||||||
|
**Empty()** - Element has no children and no attributes
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import Empty
|
||||||
|
from fasthtml.common import Div
|
||||||
|
|
||||||
|
actual = Div()
|
||||||
|
expected = Div(Empty())
|
||||||
|
|
||||||
|
matches(actual, expected) # ✅ Passes
|
||||||
|
```
|
||||||
|
|
||||||
|
**NoChildren()** - Element has no children (but can have attributes)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import NoChildren
|
||||||
|
from fasthtml.common import Div
|
||||||
|
|
||||||
|
actual = Div(id="container", cls="empty")
|
||||||
|
expected = Div(NoChildren())
|
||||||
|
|
||||||
|
matches(actual, expected) # ✅ Passes - has attributes but no children
|
||||||
|
```
|
||||||
|
|
||||||
|
**AttributeForbidden(attr_name)** - Attribute must not be present
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import AttributeForbidden
|
||||||
|
from fasthtml.common import Button
|
||||||
|
|
||||||
|
actual = Button("Click me")
|
||||||
|
expected = Button("Click me", AttributeForbidden("disabled"))
|
||||||
|
|
||||||
|
matches(actual, expected) # ✅ Passes - disabled attribute is not present
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Error Messages Explained
|
||||||
|
|
||||||
|
When a test fails, `matches()` provides a visual diff showing exactly where the problem is:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import Div, Button
|
||||||
|
|
||||||
|
actual = Div(Button("Submit", cls="btn-primary"), id="form")
|
||||||
|
expected = Div(Button("Cancel", cls="btn-secondary"))
|
||||||
|
|
||||||
|
matches(actual, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error output:**
|
||||||
|
```
|
||||||
|
Path : 'div.button'
|
||||||
|
Error : The values are different
|
||||||
|
|
||||||
|
(div "id"="form" | (div
|
||||||
|
(button "cls"="btn-prim | (button "cls"="btn-seco
|
||||||
|
^^^^^^^^^^^^^^^^ |
|
||||||
|
"Submit") | "Cancel")
|
||||||
|
^^^^^^^ |
|
||||||
|
) | )
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reading the error:**
|
||||||
|
- **Left side**: Actual element
|
||||||
|
- **Right side**: Expected pattern
|
||||||
|
- **`^^^` markers**: Highlight differences 'only on the left side', the right side (the expected pattern) is always correct
|
||||||
|
- **Path**: Shows location in the tree (`div.button` = button inside div)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### find() - Search Elements
|
||||||
|
|
||||||
|
#### Purpose
|
||||||
|
|
||||||
|
`find()` searches for all elements matching a pattern within an HTML tree. It's useful when you need to verify the presence of specific elements without knowing their exact position. Or when you want to validate (using matches) a subset of elements.
|
||||||
|
|
||||||
|
**When to use it:**
|
||||||
|
- Finding elements by attributes
|
||||||
|
- Verifying element count
|
||||||
|
- Extracting elements for further validation
|
||||||
|
- Testing without strict hierarchy requirements
|
||||||
|
|
||||||
|
#### Basic Syntax
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import find
|
||||||
|
|
||||||
|
results = find(ft, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`ft`**: Element or list of elements to search in
|
||||||
|
- **`expected`**: Pattern to match, follows the same syntax and rules as `matches()`
|
||||||
|
- **Returns**: List of all matching elements
|
||||||
|
- **Raises**: `AssertionError` if no matches found
|
||||||
|
|
||||||
|
#### Simple Examples
|
||||||
|
|
||||||
|
**Example 1: Find all elements of a type**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import find
|
||||||
|
from fasthtml.common import Div, P
|
||||||
|
|
||||||
|
page = Div(
|
||||||
|
Div(P("First paragraph")),
|
||||||
|
Div(P("Second paragraph")),
|
||||||
|
P("Third paragraph")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find all paragraphs
|
||||||
|
paragraphs = find(page, P())
|
||||||
|
|
||||||
|
assert len(paragraphs) == 3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 2: Find by attribute**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import Div, Button
|
||||||
|
from myfasthtml.test.matcher import find
|
||||||
|
|
||||||
|
page = Div(
|
||||||
|
Button("Cancel", cls="btn-secondary"),
|
||||||
|
Div(Button("Submit", cls="btn-primary", id="submit"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find the primary button
|
||||||
|
primary_buttons = find(page, Button(cls="btn-primary"))
|
||||||
|
|
||||||
|
assert len(primary_buttons) == 1
|
||||||
|
assert primary_buttons[0].attrs["id"] == "submit"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 3: Find nested structure**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import Div, Form, Input
|
||||||
|
|
||||||
|
page = Div(
|
||||||
|
Div(Input(name="search")),
|
||||||
|
Form(
|
||||||
|
Input(name="email", type="email"),
|
||||||
|
Input(name="password", type="password")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find all email inputs
|
||||||
|
email_inputs = find(page, Input(type="email"))
|
||||||
|
|
||||||
|
assert len(email_inputs) == 1
|
||||||
|
assert email_inputs[0].attrs["name"] == "email"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example 4: Search in a list**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import Div, P, Span
|
||||||
|
|
||||||
|
elements = [
|
||||||
|
Div(P("First")),
|
||||||
|
Div(P("Second")),
|
||||||
|
Span(P("Third"))
|
||||||
|
]
|
||||||
|
|
||||||
|
# Find all paragraphs across all elements
|
||||||
|
all_paragraphs = find(elements, P())
|
||||||
|
|
||||||
|
assert len(all_paragraphs) == 3
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Common Patterns
|
||||||
|
|
||||||
|
**Verify element count:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
buttons = find(page, Button())
|
||||||
|
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Check element exists:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
submit_buttons = find(page, Button(type="submit"))
|
||||||
|
assert len(submit_buttons) > 0, "No submit button found"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Extract for further testing:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
form = find(page, Form())[0] # Get first form
|
||||||
|
inputs = find(form, Input()) # Find inputs within form
|
||||||
|
assert all(inp.attrs.get("type") in ["text", "email"] for inp in inputs)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Handle missing elements:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
admin_section = find(page, Div(id="admin"))
|
||||||
|
print("Admin section found")
|
||||||
|
except AssertionError:
|
||||||
|
print("Admin section not present")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 2: Testing Rendered Components Guide
|
||||||
|
|
||||||
|
This section provides practical patterns for testing different aspects of rendered components.
|
||||||
|
|
||||||
|
### 1. Testing Element Structure
|
||||||
|
|
||||||
|
**Goal**: Verify the hierarchy and organization of elements.
|
||||||
|
|
||||||
|
**Pattern**: Use `matches()` with only the structural elements you care about.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import matches
|
||||||
|
from fasthtml.common import Div, Header, Nav, Main, Footer
|
||||||
|
|
||||||
|
# Your component renders a page layout
|
||||||
|
actual = page_component.render()
|
||||||
|
|
||||||
|
# Test only the main structure
|
||||||
|
expected = Div(
|
||||||
|
Header(Nav()),
|
||||||
|
Main(),
|
||||||
|
Footer()
|
||||||
|
)
|
||||||
|
|
||||||
|
matches(actual, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tip**: Don't include every single child element. Focus on the important structural elements.
|
||||||
|
|
||||||
|
### 2. Testing Attributes
|
||||||
|
|
||||||
|
**Goal**: Verify that specific attributes are set correctly.
|
||||||
|
|
||||||
|
**Pattern**: Include only the attributes you want to test.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import Button
|
||||||
|
|
||||||
|
# Component renders a button with HTMX
|
||||||
|
actual = button_component.render()
|
||||||
|
|
||||||
|
# Test only HTMX attributes
|
||||||
|
expected = Button(
|
||||||
|
hx_post="/api/submit",
|
||||||
|
hx_target="#result",
|
||||||
|
hx_swap="innerHTML"
|
||||||
|
)
|
||||||
|
|
||||||
|
matches(actual, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
**With predicates for dynamic values:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import StartsWith
|
||||||
|
|
||||||
|
# Test that id follows a pattern
|
||||||
|
expected = Button(id=StartsWith("btn-"))
|
||||||
|
matches(actual, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Testing Content
|
||||||
|
|
||||||
|
**Goal**: Verify text content in elements.
|
||||||
|
|
||||||
|
**Pattern**: Match elements with their text content.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import Div, H1, P
|
||||||
|
|
||||||
|
actual = article_component.render()
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
H1("Article Title"),
|
||||||
|
P("First paragraph content")
|
||||||
|
)
|
||||||
|
|
||||||
|
matches(actual, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Partial content matching:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import Contains
|
||||||
|
|
||||||
|
# Check that paragraph contains key phrase
|
||||||
|
expected = P(Contains("important information"))
|
||||||
|
matches(actual, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Testing with Predicates
|
||||||
|
|
||||||
|
**Goal**: Validate patterns rather than exact values.
|
||||||
|
|
||||||
|
**Pattern**: Use predicates for flexibility.
|
||||||
|
|
||||||
|
**Example: Testing generated IDs**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import StartsWith, AnyValue
|
||||||
|
from fasthtml.common import Div
|
||||||
|
|
||||||
|
# Component generates unique IDs
|
||||||
|
actual = widget_component.render()
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
id=StartsWith("widget-"),
|
||||||
|
data_timestamp=AnyValue() # Just check it exists
|
||||||
|
)
|
||||||
|
|
||||||
|
matches(actual, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example: Testing CSS classes**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import Contains
|
||||||
|
from fasthtml.common import Button
|
||||||
|
|
||||||
|
actual = dynamic_button.render()
|
||||||
|
|
||||||
|
# Check button has 'active' class among others
|
||||||
|
expected = Button(cls=Contains("active"))
|
||||||
|
matches(actual, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example: Forbidden attributes**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import AttributeForbidden
|
||||||
|
from fasthtml.common import Input
|
||||||
|
|
||||||
|
# Verify input is NOT disabled
|
||||||
|
actual = input_component.render()
|
||||||
|
|
||||||
|
expected = Input(
|
||||||
|
name="username",
|
||||||
|
AttributeForbidden("disabled")
|
||||||
|
)
|
||||||
|
|
||||||
|
matches(actual, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Combining matches() and find()
|
||||||
|
|
||||||
|
**Goal**: First find elements, then validate them in detail.
|
||||||
|
|
||||||
|
**Pattern**: Use `find()` to locate, then `matches()` to validate.
|
||||||
|
|
||||||
|
**Example: Testing a form**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import find, matches
|
||||||
|
from fasthtml.common import Form, Input, Button
|
||||||
|
|
||||||
|
# Render a complex page
|
||||||
|
actual = page_component.render()
|
||||||
|
|
||||||
|
# Step 1: Find the registration form
|
||||||
|
forms = find(actual, Form(id="registration"))
|
||||||
|
assert len(forms) == 1
|
||||||
|
|
||||||
|
# Step 2: Validate the form structure
|
||||||
|
registration_form = forms[0]
|
||||||
|
expected_form = Form(
|
||||||
|
Input(name="email", type="email"),
|
||||||
|
Input(name="password", type="password"),
|
||||||
|
Button("Register", type="submit")
|
||||||
|
)
|
||||||
|
|
||||||
|
matches(registration_form, expected_form)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example: Testing multiple similar elements**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from fasthtml.common import Div, Card
|
||||||
|
|
||||||
|
actual = dashboard.render()
|
||||||
|
|
||||||
|
# Find all cards
|
||||||
|
cards = find(actual, Card())
|
||||||
|
|
||||||
|
# Verify we have the right number
|
||||||
|
assert len(cards) == 3
|
||||||
|
|
||||||
|
# Validate each card has required structure
|
||||||
|
for card in cards:
|
||||||
|
expected_card = Card(
|
||||||
|
Div(cls="card-header"),
|
||||||
|
Div(cls="card-body")
|
||||||
|
)
|
||||||
|
matches(card, expected_card)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Testing Edge Cases
|
||||||
|
|
||||||
|
**Testing empty elements:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import Empty, NoChildren
|
||||||
|
from fasthtml.common import Div
|
||||||
|
|
||||||
|
# Test completely empty element
|
||||||
|
actual = Div()
|
||||||
|
expected = Div(Empty())
|
||||||
|
matches(actual, expected)
|
||||||
|
|
||||||
|
# Test element with attributes but no children
|
||||||
|
actual = Div(id="container", cls="empty-state")
|
||||||
|
expected = Div(NoChildren())
|
||||||
|
matches(actual, expected)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Testing absence of elements:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from myfasthtml.test.matcher import find
|
||||||
|
from fasthtml.common import Div
|
||||||
|
|
||||||
|
actual = basic_view.render()
|
||||||
|
|
||||||
|
# Verify admin section is not present
|
||||||
|
try:
|
||||||
|
find(actual, Div(id="admin-section"))
|
||||||
|
assert False, "Admin section should not be present"
|
||||||
|
except AssertionError as e:
|
||||||
|
if "No element found" in str(e):
|
||||||
|
pass # Expected - admin section absent
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Part 3: How It Works (Technical Overview)
|
||||||
|
|
||||||
|
Understanding how the matcher works helps you write better tests and debug failures more effectively.
|
||||||
|
|
||||||
|
### The Matching Algorithm
|
||||||
|
|
||||||
|
When you call `matches(actual, expected)`, here's what happens:
|
||||||
|
|
||||||
|
1. **Type Comparison**: Checks if elements have the same type/tag
|
||||||
|
- For FastHTML elements: compares `.tag` (e.g., "div", "button")
|
||||||
|
- For Python objects: compares class types
|
||||||
|
|
||||||
|
2. **Attribute Validation**: For each attribute in `expected`:
|
||||||
|
- Checks if attribute exists in `actual`
|
||||||
|
- If it's a `Predicate`: calls `.validate(actual_value)`
|
||||||
|
- Otherwise: checks for exact equality
|
||||||
|
- **Key point**: Only attributes in `expected` are checked
|
||||||
|
|
||||||
|
3. **Children Validation**:
|
||||||
|
- Applies `ChildrenPredicate` validators if present
|
||||||
|
- Recursively matches children in order
|
||||||
|
- **Key point**: Only checks as many children as defined in `expected`
|
||||||
|
|
||||||
|
4. **Path Tracking**: Maintains a path through the tree for error reporting
|
||||||
|
|
||||||
|
### Understanding the Path
|
||||||
|
|
||||||
|
The matcher builds a path as it traverses your element tree:
|
||||||
|
|
||||||
|
```
|
||||||
|
div#container.form.input[name=email]
|
||||||
|
```
|
||||||
|
|
||||||
|
This path means:
|
||||||
|
- Started at a `div` with `id="container"`
|
||||||
|
- Went to a `form` element
|
||||||
|
- Then to an `input` with `name="email"`
|
||||||
|
|
||||||
|
The path appears in error messages to help you locate problems:
|
||||||
|
|
||||||
|
```
|
||||||
|
Path : 'div#form.input[name=email]'
|
||||||
|
Error : 'type' is not found in Actual
|
||||||
|
```
|
||||||
|
|
||||||
|
### How Predicates Work
|
||||||
|
|
||||||
|
Predicates are objects that implement a `validate()` method:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Contains(AttrPredicate):
|
||||||
|
def validate(self, actual):
|
||||||
|
return self.value in actual
|
||||||
|
```
|
||||||
|
|
||||||
|
When matching encounters a predicate:
|
||||||
|
1. Gets the actual attribute value
|
||||||
|
2. Calls `predicate.validate(actual_value)`
|
||||||
|
3. If returns `False`, reports validation error
|
||||||
|
|
||||||
|
This allows flexible matching without hardcoding exact values.
|
||||||
|
|
||||||
|
### Error Output Generation
|
||||||
|
|
||||||
|
When a test fails, the matcher generates a side-by-side comparison:
|
||||||
|
|
||||||
|
**Process:**
|
||||||
|
1. Renders both `actual` and `expected` as tree structures
|
||||||
|
2. Compares them element by element
|
||||||
|
3. Marks differences with `^^^` characters
|
||||||
|
4. Aligns output for easy visual comparison
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```
|
||||||
|
(div "id"="old" | (div "id"="new"
|
||||||
|
^^^^ |
|
||||||
|
```
|
||||||
|
|
||||||
|
The `^^^` appears under attributes or content that don't match.
|
||||||
|
|
||||||
|
### The find() Algorithm
|
||||||
|
|
||||||
|
`find()` uses depth-first search:
|
||||||
|
|
||||||
|
1. **Check current element**: Does it match the pattern?
|
||||||
|
- If yes: add to results
|
||||||
|
|
||||||
|
2. **Search children**: Recursively search all children
|
||||||
|
|
||||||
|
3. **Return all matches**: Collects matches from entire tree
|
||||||
|
|
||||||
|
**Key difference from matches()**: `find()` looks for any occurrence anywhere in the tree, while `matches()` validates exact structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Do's ✅
|
||||||
|
|
||||||
|
**Test only what matters**
|
||||||
|
```python
|
||||||
|
# ✅ Good - tests only the submit action
|
||||||
|
expected = Button("Submit", hx_post="/api/save")
|
||||||
|
|
||||||
|
# ❌ Bad - tests irrelevant details
|
||||||
|
expected = Button("Submit", hx_post="/api/save", id="btn-123", cls="btn btn-primary")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use predicates for dynamic values**
|
||||||
|
```python
|
||||||
|
# ✅ Good - flexible for generated IDs
|
||||||
|
expected = Div(id=StartsWith("generated-"))
|
||||||
|
|
||||||
|
# ❌ Bad - brittle, will break on regeneration
|
||||||
|
expected = Div(id="generated-12345")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure tests in layers**
|
||||||
|
```python
|
||||||
|
# ✅ Good - separate concerns
|
||||||
|
# Test 1: Overall structure
|
||||||
|
matches(page, Div(Header(), Main(), Footer()))
|
||||||
|
|
||||||
|
# Test 2: Header details
|
||||||
|
header = find(page, Header())[0]
|
||||||
|
matches(header, Header(Nav(), Div(cls="user-menu")))
|
||||||
|
|
||||||
|
# ❌ Bad - everything in one giant test
|
||||||
|
matches(page, Div(
|
||||||
|
Header(Nav(...), Div(...)),
|
||||||
|
Main(...),
|
||||||
|
Footer(...)
|
||||||
|
))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verify element counts with find()**
|
||||||
|
```python
|
||||||
|
# ✅ Good - explicit count check
|
||||||
|
buttons = find(page, Button())
|
||||||
|
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
|
||||||
|
|
||||||
|
# ❌ Bad - implicit assumption
|
||||||
|
button = find(page, Button())[0] # Fails if 0 or multiple buttons
|
||||||
|
```
|
||||||
|
|
||||||
|
### Don'ts ❌
|
||||||
|
|
||||||
|
**Don't test implementation details**
|
||||||
|
```python
|
||||||
|
# ❌ Bad - internal div structure might change
|
||||||
|
expected = Div(
|
||||||
|
Div(
|
||||||
|
Div(Button("Click"))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ Good - test the important element
|
||||||
|
expected = Div(Button("Click"))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Don't use exact string matching**
|
||||||
|
```python
|
||||||
|
# ❌ Bad - fragile
|
||||||
|
assert str(component.render()) == '<div><p>Text</p></div>'
|
||||||
|
|
||||||
|
# ✅ Good - structural matching
|
||||||
|
matches(component.render(), Div(P("Text")))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Don't over-specify**
|
||||||
|
```python
|
||||||
|
# ❌ Bad - tests too much
|
||||||
|
expected = Form(
|
||||||
|
Input(name="email", type="email", id="email-input", cls="form-control"),
|
||||||
|
Input(name="password", type="password", id="pwd-input", cls="form-control"),
|
||||||
|
Button("Submit", type="submit", id="submit-btn", cls="btn btn-primary")
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ Good - tests what matters
|
||||||
|
expected = Form(
|
||||||
|
Input(name="email", type="email"),
|
||||||
|
Input(name="password", type="password"),
|
||||||
|
Button("Submit", type="submit")
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Tips
|
||||||
|
|
||||||
|
**Reuse patterns**
|
||||||
|
```python
|
||||||
|
# Define reusable patterns
|
||||||
|
STANDARD_BUTTON = Button(cls=Contains("btn"))
|
||||||
|
|
||||||
|
# Use in multiple tests
|
||||||
|
matches(actual, Div(STANDARD_BUTTON))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Use find() efficiently**
|
||||||
|
```python
|
||||||
|
# ✅ Good - specific pattern
|
||||||
|
buttons = find(page, Button(cls="primary"))
|
||||||
|
|
||||||
|
# ❌ Inefficient - too broad then filter
|
||||||
|
all_buttons = find(page, Button())
|
||||||
|
primary = [b for b in all_buttons if "primary" in b.attrs.get("cls", "")]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Import Statements
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Core functions
|
||||||
|
from myfasthtml.test.matcher import matches, find
|
||||||
|
|
||||||
|
# AttrPredicates
|
||||||
|
from myfasthtml.test.matcher import Contains, StartsWith, DoesNotContain, AnyValue
|
||||||
|
|
||||||
|
# ChildrenPredicates
|
||||||
|
from myfasthtml.test.matcher import Empty, NoChildren, AttributeForbidden
|
||||||
|
|
||||||
|
# For custom test objects
|
||||||
|
from myfasthtml.test.matcher import TestObject
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Patterns Cheatsheet
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Basic validation
|
||||||
|
matches(actual, expected)
|
||||||
|
|
||||||
|
# Find and validate
|
||||||
|
elements = find(tree, pattern)
|
||||||
|
assert len(elements) == 1
|
||||||
|
matches(elements[0], detailed_pattern)
|
||||||
|
|
||||||
|
# Flexible attribute matching
|
||||||
|
expected = Div(
|
||||||
|
id=StartsWith("prefix-"),
|
||||||
|
cls=Contains("active"),
|
||||||
|
data_value=AnyValue()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Empty element validation
|
||||||
|
expected = Div(Empty()) # No children, no attributes
|
||||||
|
expected = Div(NoChildren()) # No children, attributes OK
|
||||||
|
|
||||||
|
# Forbidden attribute
|
||||||
|
expected = Button(AttributeForbidden("disabled"))
|
||||||
|
|
||||||
|
# Multiple children
|
||||||
|
expected = Div(
|
||||||
|
Header(),
|
||||||
|
Main(),
|
||||||
|
Footer()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The matcher module provides powerful tools for testing FastHTML components:
|
||||||
|
|
||||||
|
- **`matches()`** validates structure with precise, readable error messages
|
||||||
|
- **`find()`** locates elements anywhere in your HTML tree
|
||||||
|
- **Predicates** enable flexible, maintainable tests
|
||||||
|
|
||||||
|
By focusing on what matters and using the right patterns, you can write component tests that are both robust and easy to maintain.
|
||||||
|
|
||||||
|
**Next steps:**
|
||||||
|
- Practice with simple components first
|
||||||
|
- Gradually introduce predicates as needed
|
||||||
|
- Review error messages carefully - they guide you to the problem
|
||||||
|
- Refactor tests to remove duplication
|
||||||
|
|
||||||
|
Happy testing! 🧪
|
||||||
@@ -38,7 +38,7 @@ mdurl==0.1.2
|
|||||||
more-itertools==10.8.0
|
more-itertools==10.8.0
|
||||||
myauth==0.2.1
|
myauth==0.2.1
|
||||||
mydbengine==0.1.0
|
mydbengine==0.1.0
|
||||||
myutils==0.4.0
|
myutils==0.5.0
|
||||||
nh3==0.3.1
|
nh3==0.3.1
|
||||||
numpy==2.3.5
|
numpy==2.3.5
|
||||||
oauthlib==3.3.1
|
oauthlib==3.3.1
|
||||||
|
|||||||
86
src/app.py
86
src/app.py
@@ -4,14 +4,18 @@ import yaml
|
|||||||
from fasthtml import serve
|
from fasthtml import serve
|
||||||
|
|
||||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
||||||
|
from myfasthtml.controls.DataGridsManager import DataGridsManager
|
||||||
|
from myfasthtml.controls.Dropdown import Dropdown
|
||||||
from myfasthtml.controls.FileUpload import FileUpload
|
from myfasthtml.controls.FileUpload import FileUpload
|
||||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
||||||
from myfasthtml.controls.Keyboard import Keyboard
|
from myfasthtml.controls.Keyboard import Keyboard
|
||||||
from myfasthtml.controls.Layout import Layout
|
from myfasthtml.controls.Layout import Layout
|
||||||
from myfasthtml.controls.TabsManager import TabsManager
|
from myfasthtml.controls.TabsManager import TabsManager
|
||||||
|
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import Ids, mk
|
||||||
from myfasthtml.core.instances import InstancesManager, RootInstance
|
from myfasthtml.core.instances import UniqueInstance
|
||||||
from myfasthtml.icons.carbon import volume_object_storage
|
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.icons.fluent_p3 import folder_open20_regular
|
||||||
from myfasthtml.myfastapp import create_app
|
from myfasthtml.myfastapp import create_app
|
||||||
|
|
||||||
@@ -30,41 +34,99 @@ app, rt = create_app(protect_routes=True,
|
|||||||
base_url="http://localhost:5003")
|
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("/")
|
@rt("/")
|
||||||
def index(session):
|
def index(session):
|
||||||
layout = InstancesManager.get(session, Ids.Layout, Layout, RootInstance, "Testing Layout")
|
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
|
||||||
layout.set_footer("Goodbye World")
|
layout = Layout(session_instance, "Testing Layout")
|
||||||
|
layout.footer_left.add("Goodbye World")
|
||||||
|
|
||||||
tabs_manager = TabsManager(layout, _id=f"{Ids.TabsManager}-main")
|
tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
|
||||||
|
add_tab = tabs_manager.commands.add_tab
|
||||||
btn_show_right_drawer = mk.button("show",
|
btn_show_right_drawer = mk.button("show",
|
||||||
command=layout.commands.toggle_drawer("right"),
|
command=layout.commands.toggle_drawer("right"),
|
||||||
id="btn_show_right_drawer_id")
|
id="btn_show_right_drawer_id")
|
||||||
|
|
||||||
instances_debugger = InstancesManager.get(session, Ids.InstancesDebugger, InstancesDebugger, layout)
|
instances_debugger = InstancesDebugger(layout)
|
||||||
btn_show_instances_debugger = mk.label("Instances",
|
btn_show_instances_debugger = mk.label("Instances",
|
||||||
icon=volume_object_storage,
|
icon=volume_object_storage,
|
||||||
command=tabs_manager.commands.add_tab("Instances", instances_debugger),
|
command=add_tab("Instances", instances_debugger),
|
||||||
id=instances_debugger.get_id())
|
id=instances_debugger.get_id())
|
||||||
|
|
||||||
commands_debugger = InstancesManager.get(session, Ids.CommandsDebugger, CommandsDebugger, layout)
|
commands_debugger = CommandsDebugger(layout)
|
||||||
btn_show_commands_debugger = mk.label("Commands",
|
btn_show_commands_debugger = mk.label("Commands",
|
||||||
icon=None,
|
icon=key_command16_regular,
|
||||||
command=tabs_manager.commands.add_tab("Commands", commands_debugger),
|
command=add_tab("Commands", commands_debugger),
|
||||||
id=commands_debugger.get_id())
|
id=commands_debugger.get_id())
|
||||||
|
|
||||||
btn_file_upload = mk.label("Upload",
|
btn_file_upload = mk.label("Upload",
|
||||||
icon=folder_open20_regular,
|
icon=folder_open20_regular,
|
||||||
command=tabs_manager.commands.add_tab("File Open", FileUpload(layout)),
|
command=add_tab("File Open", FileUpload(layout, _id="-file_upload")),
|
||||||
id="file_upload_id")
|
id="file_upload_id")
|
||||||
|
|
||||||
|
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_left.add(tabs_manager.add_tab_btn())
|
||||||
layout.header_right.add(btn_show_right_drawer)
|
layout.header_right.add(btn_show_right_drawer)
|
||||||
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
|
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
|
||||||
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
|
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
|
||||||
layout.left_drawer.add(btn_file_upload, "Test")
|
layout.left_drawer.add(btn_file_upload, "Test")
|
||||||
|
layout.left_drawer.add(btn_popup, "Test")
|
||||||
|
layout.left_drawer.add(tree_view, "TreeView")
|
||||||
|
layout.left_drawer.add(DataGridsManager(layout, _id="-datagrids"), "Documents")
|
||||||
layout.set_main(tabs_manager)
|
layout.set_main(tabs_manager)
|
||||||
keyboard = Keyboard(layout).add("ctrl+o", tabs_manager.commands.add_tab("File Open", FileUpload(layout)))
|
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
|
||||||
keyboard.add("ctrl+n", tabs_manager.commands.add_tab("File Open", FileUpload(layout)))
|
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||||
|
keyboard.add("ctrl+n", add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||||
return layout, keyboard
|
return layout, keyboard
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
|
--text-xs: 0.6875rem;
|
||||||
--text-sm: 0.875rem;
|
--text-sm: 0.875rem;
|
||||||
--text-sm--line-height: calc(1.25 / 0.875);
|
--text-sm--line-height: calc(1.25 / 0.875);
|
||||||
--text-xl: 1.25rem;
|
--text-xl: 1.25rem;
|
||||||
@@ -11,10 +12,11 @@
|
|||||||
--radius-md: 0.375rem;
|
--radius-md: 0.375rem;
|
||||||
--default-font-family: var(--font-sans);
|
--default-font-family: var(--font-sans);
|
||||||
--default-mono-font-family: var(--font-mono);
|
--default-mono-font-family: var(--font-mono);
|
||||||
|
--properties-font-size: var(--text-xs);
|
||||||
|
--mf-tooltip-zindex: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.mf-icon-16 {
|
.mf-icon-16 {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
min-width: 16px;
|
min-width: 16px;
|
||||||
@@ -57,6 +59,26 @@
|
|||||||
* Compatible with DaisyUI 5
|
* Compatible with DaisyUI 5
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
.mf-tooltip-container {
|
||||||
|
background: var(--color-base-200);
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none; /* Prevent interfering with mouse events */
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0; /* Default to invisible */
|
||||||
|
visibility: hidden; /* Prevent interaction when invisible */
|
||||||
|
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
|
||||||
|
position: fixed; /* Keep it above other content and adjust position */
|
||||||
|
z-index: var(--mf-tooltip-zindex); /* Ensure it's on top */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-tooltip-container[data-visible="true"] {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible; /* Show tooltip */
|
||||||
|
transition: opacity 0.3s ease; /* No delay when becoming visible */
|
||||||
|
}
|
||||||
|
|
||||||
/* Main layout container using CSS Grid */
|
/* Main layout container using CSS Grid */
|
||||||
.mf-layout {
|
.mf-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -441,3 +463,306 @@
|
|||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mf-dropdown-wrapper {
|
||||||
|
position: relative; /* CRUCIAL for the anchor */
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.mf-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0px;
|
||||||
|
z-index: 1;
|
||||||
|
width: 200px;
|
||||||
|
border: 1px solid black;
|
||||||
|
padding: 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-x: auto;
|
||||||
|
/*opacity: 0;*/
|
||||||
|
/*transition: opacity 0.2s ease-in-out;*/
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-dropdown.is-visible {
|
||||||
|
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 *************** */
|
||||||
|
/* *********************************************** */
|
||||||
|
|
||||||
|
.mf-panel {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-panel-main {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
min-width: 0; /* Important to allow the shrinking of flexbox */
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* *********************************************** */
|
||||||
|
/* ************* Properties Component ************ */
|
||||||
|
/* *********************************************** */
|
||||||
|
|
||||||
|
/* Properties container */
|
||||||
|
.mf-properties {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group card - using DaisyUI card styling */
|
||||||
|
.mf-properties-group-card {
|
||||||
|
background-color: var(--color-base-100);
|
||||||
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group header - gradient using DaisyUI primary color */
|
||||||
|
.mf-properties-group-header {
|
||||||
|
background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%);
|
||||||
|
color: var(--color-primary-content);
|
||||||
|
padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: var(--properties-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group content area */
|
||||||
|
.mf-properties-group-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Property row */
|
||||||
|
.mf-properties-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75);
|
||||||
|
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent);
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
gap: calc(var(--properties-font-size) * 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-properties-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-properties-row:hover {
|
||||||
|
background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Property key - normal font */
|
||||||
|
.mf-properties-key {
|
||||||
|
font-weight: 600;
|
||||||
|
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
|
||||||
|
flex: 0 0 40%;
|
||||||
|
font-size: var(--properties-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Property value - monospace font */
|
||||||
|
.mf-properties-value {
|
||||||
|
font-family: var(--default-mono-font-family);
|
||||||
|
color: var(--color-base-content);
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
font-size: var(--properties-font-size);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@ from ..auth.utils import (
|
|||||||
logout_user,
|
logout_user,
|
||||||
get_user_info
|
get_user_info
|
||||||
)
|
)
|
||||||
|
from ..core.instances import InstancesManager
|
||||||
|
|
||||||
|
|
||||||
def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", base_url=None):
|
def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", base_url=None):
|
||||||
@@ -181,6 +182,9 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", b
|
|||||||
if refresh_token:
|
if refresh_token:
|
||||||
logout_user(refresh_token)
|
logout_user(refresh_token)
|
||||||
|
|
||||||
|
# release memory
|
||||||
|
InstancesManager.clear_session(session)
|
||||||
|
|
||||||
# Clear session
|
# Clear session
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ class Boundaries(SingleInstance):
|
|||||||
Keep the boundaries updated
|
Keep the boundaries updated
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session, owner, container_id: str = None, on_resize=None):
|
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
|
||||||
super().__init__(session, Ids.Boundaries, owner)
|
super().__init__(owner, _id=_id)
|
||||||
self._owner = owner
|
self._owner = owner
|
||||||
self._container_id = container_id or owner.get_id()
|
self._container_id = container_id or owner.get_id()
|
||||||
self._on_resize = on_resize
|
self._on_resize = on_resize
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.commands import CommandsManager
|
from myfasthtml.core.commands import CommandsManager
|
||||||
from myfasthtml.core.instances import SingleInstance
|
from myfasthtml.core.instances import SingleInstance
|
||||||
from myfasthtml.core.network_utils import from_parent_child_list
|
from myfasthtml.core.network_utils import from_parent_child_list
|
||||||
|
|
||||||
|
|
||||||
class CommandsDebugger(SingleInstance):
|
class CommandsDebugger(SingleInstance):
|
||||||
def __init__(self, session, parent, _id=None):
|
"""
|
||||||
super().__init__(session, Ids.CommandsDebugger, parent)
|
Represents a debugger designed for visualizing and managing commands in a parent-child
|
||||||
|
hierarchical structure.
|
||||||
|
"""
|
||||||
|
def __init__(self, parent, _id=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
commands = self._get_commands()
|
commands = self._get_commands()
|
||||||
|
|||||||
59
src/myfasthtml/controls/DataGrid.py
Normal file
59
src/myfasthtml/controls/DataGrid.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fasthtml.components import Div
|
||||||
|
|
||||||
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
|
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, DataGridFooterConf, \
|
||||||
|
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
||||||
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
|
||||||
|
class DatagridState(DbObject):
|
||||||
|
def __init__(self, owner):
|
||||||
|
super().__init__(owner)
|
||||||
|
with self.initializing():
|
||||||
|
self.sidebar_visible: bool = False
|
||||||
|
self.selected_view: str = None
|
||||||
|
self.row_index: bool = False
|
||||||
|
self.columns: list[DataGridColumnState] = []
|
||||||
|
self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
|
||||||
|
self.headers: list[DataGridHeaderFooterConf] = []
|
||||||
|
self.footers: list[DataGridHeaderFooterConf] = []
|
||||||
|
self.sorted: list = []
|
||||||
|
self.filtered: dict = {}
|
||||||
|
self.edition: DatagridEditionState = DatagridEditionState()
|
||||||
|
self.selection: DatagridSelectionState = DatagridSelectionState()
|
||||||
|
|
||||||
|
|
||||||
|
class DatagridSettings(DbObject):
|
||||||
|
def __init__(self, owner):
|
||||||
|
super().__init__(owner)
|
||||||
|
with self.initializing():
|
||||||
|
self.file_name: Optional[str] = None
|
||||||
|
self.selected_sheet_name: Optional[str] = None
|
||||||
|
self.header_visible: bool = True
|
||||||
|
self.filter_all_visible: bool = True
|
||||||
|
self.views_visible: bool = True
|
||||||
|
self.open_file_visible: bool = True
|
||||||
|
self.open_settings_visible: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class Commands(BaseCommands):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DataGrid(MultipleInstance):
|
||||||
|
def __init__(self, parent, settings=None, _id=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
self._settings = DatagridSettings(self).update(settings)
|
||||||
|
self._state = DatagridState(self)
|
||||||
|
self.commands = Commands(self)
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
return Div(
|
||||||
|
self._id
|
||||||
|
)
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
58
src/myfasthtml/controls/DataGridsManager.py
Normal file
58
src/myfasthtml/controls/DataGridsManager.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from fasthtml.components import Div
|
||||||
|
|
||||||
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
|
from myfasthtml.controls.TabsManager import TabsManager
|
||||||
|
from myfasthtml.controls.TreeView import TreeView
|
||||||
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
from myfasthtml.core.instances import MultipleInstance, InstancesManager
|
||||||
|
from myfasthtml.icons.fluent_p1 import table_add20_regular
|
||||||
|
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||||
|
|
||||||
|
|
||||||
|
class Commands(BaseCommands):
|
||||||
|
def upload_from_source(self):
|
||||||
|
return Command("UploadFromSource", "Upload from source", self._owner.upload_from_source)
|
||||||
|
|
||||||
|
def new_grid(self):
|
||||||
|
return Command("NewGrid", "New grid", self._owner.new_grid)
|
||||||
|
|
||||||
|
def open_from_excel(self, tab_id, get_content_callback):
|
||||||
|
excel_content = get_content_callback()
|
||||||
|
return Command("OpenFromExcel", "Open from Excel", self._owner.open_from_excel, tab_id, excel_content)
|
||||||
|
|
||||||
|
|
||||||
|
class DataGridsManager(MultipleInstance):
|
||||||
|
def __init__(self, parent, _id=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
self.tree = TreeView(self, _id="-treeview")
|
||||||
|
self.commands = Commands(self)
|
||||||
|
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
||||||
|
|
||||||
|
def upload_from_source(self):
|
||||||
|
from myfasthtml.controls.FileUpload import FileUpload
|
||||||
|
file_upload = FileUpload(self, _id="-file-upload", auto_register=False)
|
||||||
|
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
||||||
|
tab_id = self._tabs_manager.add_tab("Upload Datagrid", file_upload)
|
||||||
|
file_upload.on_ok = self.commands.open_from_excel(tab_id, file_upload.get_content)
|
||||||
|
return self._tabs_manager.show_tab(tab_id)
|
||||||
|
|
||||||
|
def open_from_excel(self, tab_id, excel_content):
|
||||||
|
df = pd.read_excel(excel_content)
|
||||||
|
content = df.to_html(index=False)
|
||||||
|
self._tabs_manager.switch(tab_id, content)
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
return Div(
|
||||||
|
Div(
|
||||||
|
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
||||||
|
mk.icon(table_add20_regular, tooltip="New grid"),
|
||||||
|
cls="flex"
|
||||||
|
),
|
||||||
|
self.tree,
|
||||||
|
id=self._id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
99
src/myfasthtml/controls/Dropdown.py
Normal file
99
src/myfasthtml/controls/Dropdown.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
from fastcore.xml import FT
|
||||||
|
from fasthtml.components import Div
|
||||||
|
|
||||||
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
|
from myfasthtml.controls.Keyboard import Keyboard
|
||||||
|
from myfasthtml.controls.Mouse import Mouse
|
||||||
|
from myfasthtml.core.commands import Command
|
||||||
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
|
||||||
|
class Commands(BaseCommands):
|
||||||
|
def close(self):
|
||||||
|
return Command("Close", "Close Dropdown", self._owner.close).htmx(target=f"#{self._owner.get_id()}-content")
|
||||||
|
|
||||||
|
def click(self):
|
||||||
|
return Command("Click", "Click on Dropdown", self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
|
||||||
|
|
||||||
|
|
||||||
|
class DropdownState:
|
||||||
|
def __init__(self):
|
||||||
|
self.opened = False
|
||||||
|
|
||||||
|
|
||||||
|
class Dropdown(MultipleInstance):
|
||||||
|
"""
|
||||||
|
Represents a dropdown component that can be toggled open or closed. This class is used
|
||||||
|
to create interactive dropdown elements, allowing for container and button customization.
|
||||||
|
The dropdown provides functionality to manage its state, including opening, closing, and
|
||||||
|
handling user interactions.
|
||||||
|
"""
|
||||||
|
def __init__(self, parent, content=None, button=None, _id=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
self.button = Div(button) if not isinstance(button, FT) else button
|
||||||
|
self.content = content
|
||||||
|
self.commands = Commands(self)
|
||||||
|
self._state = DropdownState()
|
||||||
|
|
||||||
|
def toggle(self):
|
||||||
|
self._state.opened = not self._state.opened
|
||||||
|
return self._mk_content()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self._state.opened = False
|
||||||
|
return self._mk_content()
|
||||||
|
|
||||||
|
def on_click(self, combination, is_inside: bool):
|
||||||
|
if combination == "click":
|
||||||
|
self._state.opened = is_inside
|
||||||
|
return self._mk_content()
|
||||||
|
|
||||||
|
def _mk_content(self):
|
||||||
|
return Div(self.content,
|
||||||
|
cls=f"mf-dropdown {'is-visible' if self._state.opened else ''}",
|
||||||
|
id=f"{self._id}-content"),
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
return Div(
|
||||||
|
Div(
|
||||||
|
Div(self.button) if self.button else Div("None"),
|
||||||
|
self._mk_content(),
|
||||||
|
cls="mf-dropdown-wrapper"
|
||||||
|
),
|
||||||
|
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||||
|
Mouse(self, "-mouse").add("click", self.commands.click()),
|
||||||
|
id=self._id
|
||||||
|
)
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
|
|
||||||
|
# document.addEventListener('htmx:afterSwap', function(event) {
|
||||||
|
# const targetElement = event.detail.target; // L'élément qui a été mis à jour (#popup-unique-id)
|
||||||
|
#
|
||||||
|
# // Vérifie si c'est bien notre popup
|
||||||
|
# if (targetElement.classList.contains('mf-popup-container')) {
|
||||||
|
#
|
||||||
|
# // Trouver l'élément déclencheur HTMX (le bouton existant)
|
||||||
|
# // HTMX stocke l'élément déclencheur dans event.detail.elt
|
||||||
|
# const trigger = document.querySelector('#mon-bouton-existant');
|
||||||
|
#
|
||||||
|
# if (trigger) {
|
||||||
|
# // Obtenir les coordonnées de l'élément déclencheur par rapport à la fenêtre
|
||||||
|
# const rect = trigger.getBoundingClientRect();
|
||||||
|
#
|
||||||
|
# // L'élément du popup à positionner
|
||||||
|
# const popup = targetElement;
|
||||||
|
#
|
||||||
|
# // Appliquer la position au conteneur du popup
|
||||||
|
# // On utilise window.scrollY pour s'assurer que la position est absolue par rapport au document,
|
||||||
|
# // et non seulement à la fenêtre (car le popup est en position: absolute, pas fixed)
|
||||||
|
#
|
||||||
|
# // Top: Juste en dessous de l'élément déclencheur
|
||||||
|
# popup.style.top = (rect.bottom + window.scrollY) + 'px';
|
||||||
|
#
|
||||||
|
# // Left: Aligner avec le côté gauche de l'élément déclencheur
|
||||||
|
# popup.style.left = (rect.left + window.scrollX) + 'px';
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# });
|
||||||
@@ -6,7 +6,7 @@ from fastapi import UploadFile
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
@@ -16,7 +16,7 @@ logger = logging.getLogger("FileUpload")
|
|||||||
|
|
||||||
class FileUploadState(DbObject):
|
class FileUploadState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
# persisted in DB
|
# persisted in DB
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ class FileUploadState(DbObject):
|
|||||||
self.ns_file_name: str | None = None
|
self.ns_file_name: str | None = None
|
||||||
self.ns_sheets_names: list | None = None
|
self.ns_sheets_names: list | None = None
|
||||||
self.ns_selected_sheet_name: str | None = None
|
self.ns_selected_sheet_name: str | None = None
|
||||||
|
self.ns_file_content: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
class Commands(BaseCommands):
|
||||||
@@ -35,17 +36,25 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class FileUpload(MultipleInstance):
|
class FileUpload(MultipleInstance):
|
||||||
|
"""
|
||||||
|
Represents a file upload component.
|
||||||
|
|
||||||
def __init__(self, parent, _id=None):
|
This class provides functionality to handle the uploading process of a file,
|
||||||
super().__init__(Ids.FileUpload, parent, _id=_id)
|
extract sheet names from an Excel file, and enables users to select a specific
|
||||||
|
sheet for further processing. It integrates commands and state management
|
||||||
|
to ensure smooth operation within a parent application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, parent, _id=None, **kwargs):
|
||||||
|
super().__init__(parent, _id=_id, **kwargs)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self._state = FileUploadState(self)
|
self._state = FileUploadState(self)
|
||||||
|
|
||||||
def upload_file(self, file: UploadFile):
|
def upload_file(self, file: UploadFile):
|
||||||
logger.debug(f"upload_file: {file=}")
|
logger.debug(f"upload_file: {file=}")
|
||||||
if file:
|
if file:
|
||||||
file_content = file.file.read()
|
self._state.ns_file_content = file.file.read()
|
||||||
self._state.ns_sheets_names = self.get_sheets_names(file_content)
|
self._state.ns_sheets_names = self.get_sheets_names(self._state.ns_file_content)
|
||||||
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
|
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
|
||||||
|
|
||||||
return self.mk_sheet_selector()
|
return self.mk_sheet_selector()
|
||||||
@@ -64,6 +73,10 @@ class FileUpload(MultipleInstance):
|
|||||||
cls="select select-bordered select-sm w-full ml-2"
|
cls="select select-bordered select-sm w-full ml-2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_content(self):
|
||||||
|
return self._state.ns_file_content
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_sheets_names(file_content):
|
def get_sheets_names(file_content):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,23 +1,51 @@
|
|||||||
|
from myfasthtml.controls.Panel import Panel
|
||||||
|
from myfasthtml.controls.Properties import Properties
|
||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||||
from myfasthtml.core.network_utils import from_parent_child_list
|
from myfasthtml.core.network_utils import from_parent_child_list
|
||||||
|
|
||||||
|
|
||||||
class InstancesDebugger(SingleInstance):
|
class InstancesDebugger(SingleInstance):
|
||||||
def __init__(self, session, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(session, Ids.InstancesDebugger, parent)
|
super().__init__(parent, _id=_id)
|
||||||
|
self._panel = Panel(self, _id="-panel")
|
||||||
|
self._command = Command("ShowInstance",
|
||||||
|
"Display selected Instance",
|
||||||
|
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
instances = self._get_instances()
|
nodes, edges = self._get_nodes_and_edges()
|
||||||
nodes, edges = from_parent_child_list(instances,
|
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command})
|
||||||
id_getter=lambda x: x.get_id(),
|
return self._panel.set_main(vis_network)
|
||||||
label_getter=lambda x: x.get_prefix(),
|
|
||||||
parent_getter=lambda x: x.get_parent().get_id() if x.get_parent() else None
|
|
||||||
)
|
|
||||||
|
|
||||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges)
|
def on_network_event(self, event_data: dict):
|
||||||
return vis_network
|
session, instance_id = event_data["nodes"][0].split("#")
|
||||||
|
properties_def = {"Main": {"Id": "_id", "Parent Id": "_parent._id"},
|
||||||
|
"State": {"_name": "_state._name", "*": "_state"},
|
||||||
|
"Commands": {"*": "commands"},
|
||||||
|
}
|
||||||
|
return self._panel.set_right(Properties(self,
|
||||||
|
InstancesManager.get(session, instance_id),
|
||||||
|
properties_def,
|
||||||
|
_id="-properties"))
|
||||||
|
|
||||||
|
def _get_nodes_and_edges(self):
|
||||||
|
instances = self._get_instances()
|
||||||
|
nodes, edges = from_parent_child_list(
|
||||||
|
instances,
|
||||||
|
id_getter=lambda x: x.get_full_id(),
|
||||||
|
label_getter=lambda x: f"{x.get_id()}",
|
||||||
|
parent_getter=lambda x: x.get_full_parent_id()
|
||||||
|
)
|
||||||
|
for edge in edges:
|
||||||
|
edge["color"] = "green"
|
||||||
|
edge["arrows"] = {"to": {"enabled": False, "type": "circle"}}
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
node["shape"] = "box"
|
||||||
|
|
||||||
|
return nodes, edges
|
||||||
|
|
||||||
def _get_instances(self):
|
def _get_instances(self):
|
||||||
return list(InstancesManager.instances.values())
|
return list(InstancesManager.instances.values())
|
||||||
|
|||||||
@@ -2,14 +2,19 @@ import json
|
|||||||
|
|
||||||
from fasthtml.xtend import Script
|
from fasthtml.xtend import Script
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.commands import BaseCommand
|
from myfasthtml.core.commands import BaseCommand
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
|
||||||
class Keyboard(MultipleInstance):
|
class Keyboard(MultipleInstance):
|
||||||
def __init__(self, parent, _id=None, combinations=None):
|
"""
|
||||||
super().__init__(Ids.Keyboard, parent)
|
Represents a keyboard with customizable key combinations support.
|
||||||
|
|
||||||
|
The Keyboard class allows managing key combinations and their corresponding
|
||||||
|
actions for a given parent object.
|
||||||
|
"""
|
||||||
|
def __init__(self, parent, combinations=None, _id=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
self.combinations = combinations or {}
|
self.combinations = combinations or {}
|
||||||
|
|
||||||
def add(self, sequence: str, command: BaseCommand):
|
def add(self, sequence: str, command: BaseCommand):
|
||||||
|
|||||||
@@ -12,20 +12,22 @@ from fasthtml.common import *
|
|||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.Boundaries import Boundaries
|
from myfasthtml.controls.Boundaries import Boundaries
|
||||||
from myfasthtml.controls.UserProfile import UserProfile
|
from myfasthtml.controls.UserProfile import UserProfile
|
||||||
from myfasthtml.controls.helpers import mk, Ids
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import InstancesManager, SingleInstance
|
from myfasthtml.core.instances import SingleInstance
|
||||||
from myfasthtml.core.utils import get_id
|
from myfasthtml.core.utils import get_id
|
||||||
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
|
from myfasthtml.icons.fluent import panel_left_contract20_regular as left_drawer_contract
|
||||||
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
|
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_expand
|
||||||
|
from myfasthtml.icons.fluent_p1 import panel_right_contract20_regular as right_drawer_contract
|
||||||
|
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_expand
|
||||||
|
|
||||||
logger = logging.getLogger("LayoutControl")
|
logger = logging.getLogger("LayoutControl")
|
||||||
|
|
||||||
|
|
||||||
class LayoutState(DbObject):
|
class LayoutState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner, name=None):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner, name=name)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.left_drawer_open: bool = True
|
self.left_drawer_open: bool = True
|
||||||
self.right_drawer_open: bool = True
|
self.right_drawer_open: bool = True
|
||||||
@@ -37,12 +39,13 @@ class Commands(BaseCommands):
|
|||||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||||
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
|
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
|
||||||
|
|
||||||
def update_drawer_width(self, side: Literal["left", "right"]):
|
def update_drawer_width(self, side: Literal["left", "right"], width: int = None):
|
||||||
"""
|
"""
|
||||||
Create a command to update drawer width.
|
Create a command to update drawer width.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
side: Which drawer to update ("left" or "right")
|
side: Which drawer to update ("left" or "right")
|
||||||
|
width: New width in pixels. Given by the HTMX request
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Command: Command object for updating drawer width
|
Command: Command object for updating drawer width
|
||||||
@@ -100,7 +103,7 @@ class Layout(SingleInstance):
|
|||||||
def get_groups(self):
|
def get_groups(self):
|
||||||
return self._groups
|
return self._groups
|
||||||
|
|
||||||
def __init__(self, session, app_name, parent=None):
|
def __init__(self, parent, app_name, _id=None):
|
||||||
"""
|
"""
|
||||||
Initialize the Layout component.
|
Initialize the Layout component.
|
||||||
|
|
||||||
@@ -109,13 +112,13 @@ class Layout(SingleInstance):
|
|||||||
left_drawer (bool): Enable left drawer. Default is True.
|
left_drawer (bool): Enable left drawer. Default is True.
|
||||||
right_drawer (bool): Enable right drawer. Default is True.
|
right_drawer (bool): Enable right drawer. Default is True.
|
||||||
"""
|
"""
|
||||||
super().__init__(session, Ids.Layout, parent)
|
super().__init__(parent, _id=_id)
|
||||||
self.app_name = app_name
|
self.app_name = app_name
|
||||||
|
|
||||||
# Content storage
|
# Content storage
|
||||||
self._main_content = None
|
self._main_content = None
|
||||||
self._state = LayoutState(self)
|
self._state = LayoutState(self, "default_layout")
|
||||||
self._boundaries = Boundaries(session, self)
|
self._boundaries = Boundaries(self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self.left_drawer = self.Content(self)
|
self.left_drawer = self.Content(self)
|
||||||
self.right_drawer = self.Content(self)
|
self.right_drawer = self.Content(self)
|
||||||
@@ -124,15 +127,6 @@ class Layout(SingleInstance):
|
|||||||
self.footer_left = self.Content(self)
|
self.footer_left = self.Content(self)
|
||||||
self.footer_right = self.Content(self)
|
self.footer_right = self.Content(self)
|
||||||
|
|
||||||
def set_footer(self, content):
|
|
||||||
"""
|
|
||||||
Set the footer content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
content: FastHTML component(s) or content for the footer
|
|
||||||
"""
|
|
||||||
self._footer_content = content
|
|
||||||
|
|
||||||
def set_main(self, content):
|
def set_main(self, content):
|
||||||
"""
|
"""
|
||||||
Set the main content area.
|
Set the main content area.
|
||||||
@@ -141,8 +135,21 @@ class Layout(SingleInstance):
|
|||||||
content: FastHTML component(s) or content for the main area
|
content: FastHTML component(s) or content for the main area
|
||||||
"""
|
"""
|
||||||
self._main_content = content
|
self._main_content = content
|
||||||
|
return self
|
||||||
|
|
||||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||||
|
"""
|
||||||
|
Toggle the state of a drawer (open or close) based on the specified side. This
|
||||||
|
method also generates the corresponding icon and drawer elements for the
|
||||||
|
selected side.
|
||||||
|
|
||||||
|
:param side: The side of the drawer to toggle. Must be either "left" or "right".
|
||||||
|
:type side: Literal["left", "right"]
|
||||||
|
:return: A tuple containing the updated drawer icon and drawer elements for
|
||||||
|
the specified side.
|
||||||
|
:rtype: Tuple[Any, Any]
|
||||||
|
:raises ValueError: If the provided `side` is not "left" or "right".
|
||||||
|
"""
|
||||||
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
|
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
|
||||||
if side == "left":
|
if side == "left":
|
||||||
self._state.left_drawer_open = not self._state.left_drawer_open
|
self._state.left_drawer_open = not self._state.left_drawer_open
|
||||||
@@ -188,15 +195,17 @@ class Layout(SingleInstance):
|
|||||||
return Header(
|
return Header(
|
||||||
Div( # left
|
Div( # left
|
||||||
self._mk_left_drawer_icon(),
|
self._mk_left_drawer_icon(),
|
||||||
*self.header_left.get_content(),
|
*self._mk_content_wrapper(self.header_left, horizontal=True, show_group_name=False).children,
|
||||||
cls="flex gap-1"
|
cls="flex gap-1",
|
||||||
|
id=f"{self._id}_hl"
|
||||||
),
|
),
|
||||||
Div( # right
|
Div( # right
|
||||||
*self.header_right.get_content(),
|
*self._mk_content_wrapper(self.header_right, horizontal=True, show_group_name=False).children,
|
||||||
InstancesManager.get(self._session, Ids.UserProfile, UserProfile),
|
UserProfile(self),
|
||||||
cls="flex gap-1"
|
cls="flex gap-1",
|
||||||
|
id=f"{self._id}_hr"
|
||||||
),
|
),
|
||||||
cls="mf-layout-header"
|
cls="mf-layout-header",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_footer(self):
|
def _mk_footer(self):
|
||||||
@@ -206,9 +215,17 @@ class Layout(SingleInstance):
|
|||||||
Returns:
|
Returns:
|
||||||
Footer: FastHTML Footer component
|
Footer: FastHTML Footer component
|
||||||
"""
|
"""
|
||||||
footer_content = self._footer_content if self._footer_content else ""
|
|
||||||
return Footer(
|
return Footer(
|
||||||
footer_content,
|
Div( # left
|
||||||
|
*self._mk_content_wrapper(self.footer_left, horizontal=True, show_group_name=False).children,
|
||||||
|
cls="flex gap-1",
|
||||||
|
id=f"{self._id}_fl"
|
||||||
|
),
|
||||||
|
Div( # right
|
||||||
|
*self._mk_content_wrapper(self.footer_right, horizontal=True, show_group_name=False).children,
|
||||||
|
cls="flex gap-1",
|
||||||
|
id=f"{self._id}_fr"
|
||||||
|
),
|
||||||
cls="mf-layout-footer footer sm:footer-horizontal"
|
cls="mf-layout-footer footer sm:footer-horizontal"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -233,7 +250,7 @@ class Layout(SingleInstance):
|
|||||||
Div: FastHTML Div component for left drawer
|
Div: FastHTML Div component for left drawer
|
||||||
"""
|
"""
|
||||||
resizer = Div(
|
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_command_id=self.commands.update_drawer_width("left").id,
|
||||||
data_side="left"
|
data_side="left"
|
||||||
)
|
)
|
||||||
@@ -266,15 +283,23 @@ class Layout(SingleInstance):
|
|||||||
Returns:
|
Returns:
|
||||||
Div: FastHTML Div component for right drawer
|
Div: FastHTML Div component for right drawer
|
||||||
"""
|
"""
|
||||||
|
|
||||||
resizer = Div(
|
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_command_id=self.commands.update_drawer_width("right").id,
|
||||||
data_side="right"
|
data_side="right"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wrap content in scrollable container
|
# Wrap content in scrollable container
|
||||||
content_wrapper = Div(
|
content_wrapper = Div(
|
||||||
*self.right_drawer.get_content(),
|
*[
|
||||||
|
(
|
||||||
|
Div(cls="divider") if index > 0 else None,
|
||||||
|
group_ft,
|
||||||
|
*[item for item in self.right_drawer.get_content()[group_name]]
|
||||||
|
)
|
||||||
|
for index, (group_name, group_ft) in enumerate(self.right_drawer.get_groups())
|
||||||
|
],
|
||||||
cls="mf-layout-drawer-content"
|
cls="mf-layout-drawer-content"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -287,15 +312,29 @@ class Layout(SingleInstance):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _mk_left_drawer_icon(self):
|
def _mk_left_drawer_icon(self):
|
||||||
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
|
return mk.icon(left_drawer_contract if self._state.left_drawer_open else left_drawer_expand,
|
||||||
id=f"{self._id}_ldi",
|
id=f"{self._id}_ldi",
|
||||||
command=self.commands.toggle_drawer("left"))
|
command=self.commands.toggle_drawer("left"))
|
||||||
|
|
||||||
def _mk_right_drawer_icon(self):
|
def _mk_right_drawer_icon(self):
|
||||||
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
|
return mk.icon(right_drawer_contract if self._state.right_drawer_open else right_drawer_expand,
|
||||||
id=f"{self._id}_rdi",
|
id=f"{self._id}_rdi",
|
||||||
command=self.commands.toggle_drawer("right"))
|
command=self.commands.toggle_drawer("right"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _mk_content_wrapper(content: Content, show_group_name: bool = True, horizontal: bool = False):
|
||||||
|
return Div(
|
||||||
|
*[
|
||||||
|
(
|
||||||
|
Div(cls=f"divider {'divider-horizontal' if horizontal else ''}") if index > 0 else None,
|
||||||
|
group_ft if show_group_name else None,
|
||||||
|
*[item for item in content.get_content()[group_name]]
|
||||||
|
)
|
||||||
|
for index, (group_name, group_ft) in enumerate(content.get_groups())
|
||||||
|
],
|
||||||
|
cls="mf-layout-drawer-content"
|
||||||
|
)
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
"""
|
"""
|
||||||
Render the complete layout.
|
Render the complete layout.
|
||||||
@@ -306,12 +345,13 @@ class Layout(SingleInstance):
|
|||||||
|
|
||||||
# Wrap everything in a container div
|
# Wrap everything in a container div
|
||||||
return Div(
|
return Div(
|
||||||
|
Div(id=f"tt_{self._id}", cls="mf-tooltip-container"), # container for the tooltips
|
||||||
self._mk_header(),
|
self._mk_header(),
|
||||||
self._mk_left_drawer(),
|
self._mk_left_drawer(),
|
||||||
self._mk_main(),
|
self._mk_main(),
|
||||||
self._mk_right_drawer(),
|
self._mk_right_drawer(),
|
||||||
self._mk_footer(),
|
self._mk_footer(),
|
||||||
Script(f"initLayoutResizer('{self._id}');"),
|
Script(f"initLayout('{self._id}');"),
|
||||||
id=self._id,
|
id=self._id,
|
||||||
cls="mf-layout",
|
cls="mf-layout",
|
||||||
)
|
)
|
||||||
|
|||||||
29
src/myfasthtml/controls/Mouse.py
Normal file
29
src/myfasthtml/controls/Mouse.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from fasthtml.xtend import Script
|
||||||
|
|
||||||
|
from myfasthtml.core.commands import BaseCommand
|
||||||
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
|
||||||
|
class Mouse(MultipleInstance):
|
||||||
|
"""
|
||||||
|
Represents a mechanism to manage mouse event combinations and their associated commands.
|
||||||
|
|
||||||
|
This class is used to add, manage, and render mouse event sequences with corresponding
|
||||||
|
commands, providing a flexible way to handle mouse interactions programmatically.
|
||||||
|
"""
|
||||||
|
def __init__(self, parent, _id=None, combinations=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
self.combinations = combinations or {}
|
||||||
|
|
||||||
|
def add(self, sequence: str, command: BaseCommand):
|
||||||
|
self.combinations[sequence] = command
|
||||||
|
return self
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
str_combinations = {sequence: command.get_htmx_params() for sequence, command in self.combinations.items()}
|
||||||
|
return Script(f"add_mouse_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
111
src/myfasthtml/controls/Panel.py
Normal file
111
src/myfasthtml/controls/Panel.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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):
|
||||||
|
"""
|
||||||
|
Represents a user interface panel that supports customizable left, main, and right components.
|
||||||
|
|
||||||
|
The `Panel` class is used to create and manage a panel layout with optional left, main,
|
||||||
|
and right sections. It provides functionality to set the components of the panel, toggle
|
||||||
|
sides, and adjust the width of the sides dynamically. The class also handles rendering
|
||||||
|
the panel with appropriate HTML elements and JavaScript for interactivity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 Div(self._right, id=f"{self._id}_r")
|
||||||
|
|
||||||
|
def set_left(self, left):
|
||||||
|
self._left = left
|
||||||
|
return Div(self._left, id=f"{self._id}_l")
|
||||||
|
|
||||||
|
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, Div(self._right, id=f"{self._id}_r"), 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(Div(self._left, id=f"{self._id}_l"), 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()
|
||||||
50
src/myfasthtml/controls/Properties.py
Normal file
50
src/myfasthtml/controls/Properties.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from fasthtml.components import Div
|
||||||
|
from myutils.ProxyObject import ProxyObject
|
||||||
|
|
||||||
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
|
||||||
|
class Properties(MultipleInstance):
|
||||||
|
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
|
||||||
|
super().__init__(parent, _id=_id)
|
||||||
|
self.obj = obj
|
||||||
|
self.groups = groups
|
||||||
|
self.properties_by_group = self._create_properties_by_group()
|
||||||
|
|
||||||
|
def set_obj(self, obj, groups: dict = None):
|
||||||
|
self.obj = obj
|
||||||
|
self.groups = groups
|
||||||
|
self.properties_by_group = self._create_properties_by_group()
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
return Div(
|
||||||
|
*[
|
||||||
|
Div(
|
||||||
|
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
||||||
|
Div(
|
||||||
|
*[
|
||||||
|
Div(
|
||||||
|
Div(k, cls="mf-properties-key"),
|
||||||
|
Div(str(v), cls="mf-properties-value", title=str(v)),
|
||||||
|
cls="mf-properties-row"
|
||||||
|
)
|
||||||
|
for k, v in proxy.as_dict().items()
|
||||||
|
],
|
||||||
|
cls="mf-properties-group-content"
|
||||||
|
),
|
||||||
|
cls="mf-properties-group-card"
|
||||||
|
)
|
||||||
|
for group_name, proxy in self.properties_by_group.items()
|
||||||
|
],
|
||||||
|
id=self._id,
|
||||||
|
cls="mf-properties"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_properties_by_group(self):
|
||||||
|
if self.groups is None:
|
||||||
|
return {None: ProxyObject(self.obj, {"*": ""})}
|
||||||
|
|
||||||
|
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()}
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
@@ -4,7 +4,7 @@ from typing import Callable, Any
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
||||||
from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching
|
from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching
|
||||||
@@ -21,6 +21,21 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class Search(MultipleInstance):
|
class Search(MultipleInstance):
|
||||||
|
"""
|
||||||
|
Represents a component for managing and filtering a list of items.
|
||||||
|
It uses fuzzy matching and subsequence matching to filter items.
|
||||||
|
|
||||||
|
:ivar items_names: The name of the items used to filter.
|
||||||
|
:type items_names: str
|
||||||
|
:ivar items: The first set of items to filter.
|
||||||
|
:type items: list
|
||||||
|
:ivar filtered: A copy of the `items` list, representing the filtered items after a search operation.
|
||||||
|
:type filtered: list
|
||||||
|
:ivar get_attr: Callable function to extract string values from items for filtering.
|
||||||
|
:type get_attr: Callable[[Any], str]
|
||||||
|
:ivar template: Callable function to define how filtered items are rendered.
|
||||||
|
:type template: Callable[[Any], Any]
|
||||||
|
"""
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
parent: BaseInstance,
|
parent: BaseInstance,
|
||||||
_id=None,
|
_id=None,
|
||||||
@@ -35,14 +50,13 @@ class Search(MultipleInstance):
|
|||||||
a callable for extracting a string value from items, and a template callable for rendering
|
a callable for extracting a string value from items, and a template callable for rendering
|
||||||
the filtered items. It provides functionality to handle and organize item-based operations.
|
the filtered items. It provides functionality to handle and organize item-based operations.
|
||||||
|
|
||||||
:param session: The session object to maintain state or context across operations.
|
|
||||||
:param _id: Optional identifier for the component.
|
:param _id: Optional identifier for the component.
|
||||||
:param items: An optional list of names for the items to be filtered.
|
:param items: An optional list of names for the items to be filtered.
|
||||||
:param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a
|
:param get_attr: Callable function to extract a string value from an item for filtering. Defaults to a
|
||||||
function that returns the item as is.
|
function that returns the item as is.
|
||||||
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
|
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
|
||||||
"""
|
"""
|
||||||
super().__init__(Ids.Search, parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.items_names = items_names or ''
|
self.items_names = items_names or ''
|
||||||
self.items = items or []
|
self.items = items or []
|
||||||
self.filtered = self.items.copy()
|
self.filtered = self.items.copy()
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ from fasthtml.xtend import Script
|
|||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.Search import Search
|
from myfasthtml.controls.Search import Search
|
||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
from myfasthtml.core.instances import MultipleInstance, BaseInstance, InstancesManager
|
||||||
from myfasthtml.core.instances_helper import InstancesHelper
|
|
||||||
from myfasthtml.icons.fluent_p1 import tabs24_regular
|
from myfasthtml.icons.fluent_p1 import tabs24_regular
|
||||||
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
|
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ class Boundaries:
|
|||||||
|
|
||||||
class TabsManagerState(DbObject):
|
class TabsManagerState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
# persisted in DB
|
# persisted in DB
|
||||||
self.tabs: dict[str, Any] = {}
|
self.tabs: dict[str, Any] = {}
|
||||||
@@ -78,14 +77,15 @@ class TabsManager(MultipleInstance):
|
|||||||
_tab_count = 0
|
_tab_count = 0
|
||||||
|
|
||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(Ids.TabsManager, parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self._state = TabsManagerState(self)
|
self._state = TabsManagerState(self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self._boundaries = Boundaries()
|
self._boundaries = Boundaries()
|
||||||
self._search = Search(self,
|
self._search = Search(self,
|
||||||
items=self._get_tab_list(),
|
items=self._get_tab_list(),
|
||||||
get_attr=lambda x: x["label"],
|
get_attr=lambda x: x["label"],
|
||||||
template=self._mk_tab_button)
|
template=self._mk_tab_button,
|
||||||
|
_id="-search")
|
||||||
logger.debug(f"TabsManager created with id: {self._id}")
|
logger.debug(f"TabsManager created with id: {self._id}")
|
||||||
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
||||||
logger.debug(f" active tab : {self._state.active_tab}")
|
logger.debug(f" active tab : {self._state.active_tab}")
|
||||||
@@ -102,7 +102,11 @@ class TabsManager(MultipleInstance):
|
|||||||
tab_config = self._state.tabs[tab_id]
|
tab_config = self._state.tabs[tab_id]
|
||||||
if tab_config["component_type"] is None:
|
if tab_config["component_type"] is None:
|
||||||
return None
|
return None
|
||||||
return InstancesHelper.dynamic_get(self, tab_config["component_type"], tab_config["component_id"])
|
try:
|
||||||
|
return InstancesManager.get(self._session, tab_config["component_id"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error while retrieving tab content: {e}")
|
||||||
|
return Div("Tab not found.")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_tab_count():
|
def _get_tab_count():
|
||||||
@@ -203,6 +207,11 @@ class TabsManager(MultipleInstance):
|
|||||||
logger.debug(f" Content already exists. Just switch.")
|
logger.debug(f" Content already exists. Just switch.")
|
||||||
return self._mk_tabs_controller()
|
return self._mk_tabs_controller()
|
||||||
|
|
||||||
|
def switch_tab(self, tab_id, label, component, activate=True):
|
||||||
|
logger.debug(f"switch_tab {label=}, component={component}, activate={activate}")
|
||||||
|
self._add_or_update_tab(tab_id, label, component, activate)
|
||||||
|
return self.show_tab(tab_id) #
|
||||||
|
|
||||||
def close_tab(self, tab_id: str):
|
def close_tab(self, tab_id: str):
|
||||||
"""
|
"""
|
||||||
Close a tab and remove it from the tabs manager.
|
Close a tab and remove it from the tabs manager.
|
||||||
@@ -382,6 +391,34 @@ class TabsManager(MultipleInstance):
|
|||||||
def _get_tab_list(self):
|
def _get_tab_list(self):
|
||||||
return [self._state.tabs[tab_id] for tab_id in self._state.tabs_order if tab_id in self._state.tabs]
|
return [self._state.tabs[tab_id] for tab_id in self._state.tabs_order if tab_id in self._state.tabs]
|
||||||
|
|
||||||
|
def _add_or_update_tab(self, tab_id, label, component, activate):
|
||||||
|
state = self._state.copy()
|
||||||
|
|
||||||
|
# Extract component ID if the component has a get_id() method
|
||||||
|
component_type, component_id = None, None
|
||||||
|
if isinstance(component, BaseInstance):
|
||||||
|
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
|
||||||
|
component_id = component.get_id()
|
||||||
|
|
||||||
|
# Add tab metadata to state
|
||||||
|
state.tabs[tab_id] = {
|
||||||
|
'id': tab_id,
|
||||||
|
'label': label,
|
||||||
|
'component_type': component_type,
|
||||||
|
'component_id': component_id
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add the content
|
||||||
|
state._tabs_content[tab_id] = component
|
||||||
|
|
||||||
|
# Activate tab if requested
|
||||||
|
if activate:
|
||||||
|
state.active_tab = tab_id
|
||||||
|
|
||||||
|
# finally, update the state
|
||||||
|
self._state.update(state)
|
||||||
|
self._search.set_items(self._get_tab_list())
|
||||||
|
|
||||||
def update_boundaries(self):
|
def update_boundaries(self):
|
||||||
return Script(f"updateBoundaries('{self._id}');")
|
return Script(f"updateBoundaries('{self._id}');")
|
||||||
|
|
||||||
|
|||||||
399
src/myfasthtml/controls/TreeView.py
Normal file
399
src/myfasthtml/controls/TreeView.py
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
"""
|
||||||
|
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 None,
|
||||||
|
command=self.commands.toggle_node(node_id))
|
||||||
|
|
||||||
|
# Label or input for editing
|
||||||
|
if is_editing:
|
||||||
|
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 ''}",
|
||||||
|
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",
|
||||||
|
data_node_id=node_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.helpers import Ids, mk
|
from myfasthtml.controls.helpers import mk
|
||||||
|
from myfasthtml.core.AuthProxy import AuthProxy
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
from myfasthtml.core.instances import SingleInstance, RootInstance
|
||||||
from myfasthtml.core.utils import retrieve_user_info
|
from myfasthtml.core.utils import retrieve_user_info
|
||||||
from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp
|
from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp
|
||||||
from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled
|
from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled
|
||||||
@@ -15,6 +16,7 @@ class UserProfileState:
|
|||||||
self._session = owner.get_session()
|
self._session = owner.get_session()
|
||||||
|
|
||||||
self.theme = "light"
|
self.theme = "light"
|
||||||
|
self.load()
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
user_info = retrieve_user_info(self._session)
|
user_info = retrieve_user_info(self._session)
|
||||||
@@ -25,7 +27,7 @@ class UserProfileState:
|
|||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
||||||
auth_proxy = InstancesManager.get_auth_proxy()
|
auth_proxy = AuthProxy(RootInstance)
|
||||||
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
|
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
|
||||||
|
|
||||||
|
|
||||||
@@ -35,14 +37,15 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class UserProfile(SingleInstance):
|
class UserProfile(SingleInstance):
|
||||||
def __init__(self, session, parent=None):
|
def __init__(self, parent=None, _id=None):
|
||||||
super().__init__(session, Ids.UserProfile, parent)
|
super().__init__(parent, _id=_id)
|
||||||
self._state = UserProfileState(self)
|
self._state = UserProfileState(self)
|
||||||
self._commands = Commands(self)
|
self._commands = Commands(self)
|
||||||
|
|
||||||
def update_dark_mode(self, client_response):
|
def update_dark_mode(self, client_response):
|
||||||
self._state.theme = client_response.get("theme", "light")
|
self._state.theme = client_response.get("theme", "light")
|
||||||
self._state.save()
|
self._state.save()
|
||||||
|
retrieve_user_info(self._session).get("user_settings", {})["theme"] = self._state.theme
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
user_info = retrieve_user_info(self._session)
|
user_info = retrieve_user_info(self._session)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import logging
|
|||||||
|
|
||||||
from fasthtml.components import Script, Div
|
from fasthtml.components import Script, Div
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@ logger = logging.getLogger("VisNetwork")
|
|||||||
|
|
||||||
class VisNetworkState(DbObject):
|
class VisNetworkState(DbObject):
|
||||||
def __init__(self, owner):
|
def __init__(self, owner):
|
||||||
super().__init__(owner.get_session(), owner.get_id())
|
super().__init__(owner)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
# persisted in DB
|
# persisted in DB
|
||||||
self.nodes: list = []
|
self.nodes: list = []
|
||||||
@@ -26,19 +25,33 @@ class VisNetworkState(DbObject):
|
|||||||
},
|
},
|
||||||
"physics": {"enabled": True}
|
"physics": {"enabled": True}
|
||||||
}
|
}
|
||||||
|
self.events_handlers: dict = {} # {event_name: command_url}
|
||||||
|
|
||||||
|
|
||||||
class VisNetwork(MultipleInstance):
|
class VisNetwork(MultipleInstance):
|
||||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
|
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None, events_handlers=None):
|
||||||
super().__init__(Ids.VisNetwork, parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
logger.debug(f"VisNetwork created with id: {self._id}")
|
logger.debug(f"VisNetwork created with id: {self._id}")
|
||||||
|
|
||||||
self._state = VisNetworkState(self)
|
# possible events (expected in snake_case
|
||||||
self._update_state(nodes, edges, options)
|
# - select_node → selectNode
|
||||||
|
# - select → select
|
||||||
|
# - click → click
|
||||||
|
# - double_click → doubleClick
|
||||||
|
|
||||||
def _update_state(self, nodes, edges, options):
|
self._state = VisNetworkState(self)
|
||||||
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}")
|
|
||||||
if not nodes and not edges and not options:
|
# Convert Commands to URLs
|
||||||
|
handlers_htmx_options = {
|
||||||
|
event_name: command.ajax_htmx_options()
|
||||||
|
for event_name, command in events_handlers.items()
|
||||||
|
} if events_handlers else {}
|
||||||
|
|
||||||
|
self._update_state(nodes, edges, options, handlers_htmx_options)
|
||||||
|
|
||||||
|
def _update_state(self, nodes, edges, options, events_handlers=None):
|
||||||
|
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}, {events_handlers=}")
|
||||||
|
if not nodes and not edges and not options and not events_handlers:
|
||||||
return
|
return
|
||||||
|
|
||||||
state = self._state.copy()
|
state = self._state.copy()
|
||||||
@@ -48,9 +61,17 @@ class VisNetwork(MultipleInstance):
|
|||||||
state.edges = edges
|
state.edges = edges
|
||||||
if options is not None:
|
if options is not None:
|
||||||
state.options = options
|
state.options = options
|
||||||
|
if events_handlers is not None:
|
||||||
|
state.events_handlers = events_handlers
|
||||||
|
|
||||||
self._state.update(state)
|
self._state.update(state)
|
||||||
|
|
||||||
|
def add_to_options(self, **kwargs):
|
||||||
|
logger.debug(f"add_to_options: {kwargs=}")
|
||||||
|
new_options = self._state.options.copy() | kwargs
|
||||||
|
self._update_state(None, None, new_options)
|
||||||
|
return self
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
|
|
||||||
# Serialize nodes and edges to JSON
|
# Serialize nodes and edges to JSON
|
||||||
@@ -65,6 +86,34 @@ class VisNetwork(MultipleInstance):
|
|||||||
# Convert Python options to JS
|
# Convert Python options to JS
|
||||||
js_options = json.dumps(self._state.options, indent=2)
|
js_options = json.dumps(self._state.options, indent=2)
|
||||||
|
|
||||||
|
# Map Python event names to vis-network event names
|
||||||
|
event_name_map = {
|
||||||
|
"select_node": "selectNode",
|
||||||
|
"select": "select",
|
||||||
|
"click": "click",
|
||||||
|
"double_click": "doubleClick"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate event handlers JavaScript
|
||||||
|
event_handlers_js = ""
|
||||||
|
for event_name, command_htmx_options in self._state.events_handlers.items():
|
||||||
|
vis_event_name = event_name_map.get(event_name, event_name)
|
||||||
|
event_handlers_js += f"""
|
||||||
|
network.on('{vis_event_name}', function(params) {{
|
||||||
|
const event_data = {{
|
||||||
|
event_name: '{event_name}',
|
||||||
|
nodes: params.nodes,
|
||||||
|
edges: params.edges,
|
||||||
|
pointer: params.pointer
|
||||||
|
}};
|
||||||
|
htmx.ajax('POST', '{command_htmx_options['url']}', {{
|
||||||
|
values: {{event_data: JSON.stringify(event_data)}},
|
||||||
|
target: '{command_htmx_options['target']}',
|
||||||
|
swap: '{command_htmx_options['swap']}'
|
||||||
|
}});
|
||||||
|
}});
|
||||||
|
"""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Div(
|
Div(
|
||||||
id=self._id,
|
id=self._id,
|
||||||
@@ -87,6 +136,7 @@ class VisNetwork(MultipleInstance):
|
|||||||
}};
|
}};
|
||||||
const options = {js_options};
|
const options = {js_options};
|
||||||
const network = new vis.Network(container, data, options);
|
const network = new vis.Network(container, data, options);
|
||||||
|
{event_handlers_js}
|
||||||
}})();
|
}})();
|
||||||
""")
|
""")
|
||||||
)
|
)
|
||||||
|
|||||||
49
src/myfasthtml/controls/datagrid_objects.py
Normal file
49
src/myfasthtml/controls/datagrid_objects.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from myfasthtml.core.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DataGridRowState:
|
||||||
|
row_id: int
|
||||||
|
visible: bool = True
|
||||||
|
height: int | None = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DataGridColumnState:
|
||||||
|
col_id: str # name of the column: cannot be changed
|
||||||
|
col_index: int # index of the column in the dataframe: cannot be changed
|
||||||
|
title: str = None
|
||||||
|
type: ColumnType = ColumnType.Text
|
||||||
|
visible: bool = True
|
||||||
|
usable: bool = True
|
||||||
|
width: int = DEFAULT_COLUMN_WIDTH
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DatagridEditionState:
|
||||||
|
under_edition: tuple[int, int] | None = None
|
||||||
|
previous_under_edition: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DatagridSelectionState:
|
||||||
|
selected: tuple[int, int] | None = None
|
||||||
|
last_selected: tuple[int, int] | None = None
|
||||||
|
selection_mode: str = None # valid values are "row", "column" or None for "cell"
|
||||||
|
extra_selected: list[tuple[str, str | int]] = field(default_factory=list) # list(tuple(selection_mode, element_id))
|
||||||
|
last_extra_selected: tuple[int, int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DataGridHeaderFooterConf:
|
||||||
|
conf: dict[str, str] = field(default_factory=dict) # first 'str' is the column id
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DatagridView:
|
||||||
|
name: str
|
||||||
|
type: ViewType = ViewType.Table
|
||||||
|
columns: list[DataGridColumnState] = None
|
||||||
@@ -7,25 +7,27 @@ from myfasthtml.core.utils import merge_classes
|
|||||||
|
|
||||||
class Ids:
|
class Ids:
|
||||||
# Please keep the alphabetical order
|
# Please keep the alphabetical order
|
||||||
AuthProxy = "mf-auth-proxy"
|
|
||||||
Boundaries = "mf-boundaries"
|
|
||||||
CommandsDebugger = "mf-commands-debugger"
|
|
||||||
DbManager = "mf-dbmanager"
|
|
||||||
FileUpload = "mf-file-upload"
|
|
||||||
InstancesDebugger = "mf-instances-debugger"
|
|
||||||
Keyboard = "mf-keyboard"
|
|
||||||
Layout = "mf-layout"
|
|
||||||
Root = "mf-root"
|
Root = "mf-root"
|
||||||
Search = "mf-search"
|
UserSession = "mf-user_session"
|
||||||
TabsManager = "mf-tabs-manager"
|
|
||||||
UserProfile = "mf-user-profile"
|
|
||||||
VisNetwork = "mf-vis-network"
|
|
||||||
|
|
||||||
|
|
||||||
class mk:
|
class mk:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
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)
|
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -44,19 +46,45 @@ class mk:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def icon(icon, size=20,
|
def icon(icon,
|
||||||
|
size=20,
|
||||||
can_select=True,
|
can_select=True,
|
||||||
can_hover=False,
|
can_hover=False,
|
||||||
|
tooltip=None,
|
||||||
cls='',
|
cls='',
|
||||||
command: Command = None,
|
command: Command = None,
|
||||||
binding: Binding = None,
|
binding: Binding = None,
|
||||||
**kwargs):
|
**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 tooltip:
|
||||||
|
: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}",
|
merged_cls = merge_classes(f"mf-icon-{size}",
|
||||||
'icon-btn' if can_select else '',
|
'icon-btn' if can_select else '',
|
||||||
'mmt-btn' if can_hover else '',
|
'mmt-btn' if can_hover else '',
|
||||||
cls,
|
cls,
|
||||||
kwargs)
|
kwargs)
|
||||||
|
|
||||||
|
if tooltip:
|
||||||
|
merged_cls = merge_classes(merged_cls, "mf-tooltip")
|
||||||
|
kwargs["data-tooltip"] = tooltip
|
||||||
|
|
||||||
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
|
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
from myfasthtml.auth.utils import login_user, save_user_info, register_user
|
from myfasthtml.auth.utils import login_user, save_user_info, register_user
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.core.instances import SingleInstance
|
||||||
from myfasthtml.core.instances import UniqueInstance, RootInstance
|
|
||||||
|
|
||||||
|
|
||||||
class AuthProxy(UniqueInstance):
|
class AuthProxy(SingleInstance):
|
||||||
def __init__(self, base_url: str = None):
|
def __init__(self, parent, base_url: str = None):
|
||||||
super().__init__(Ids.AuthProxy, RootInstance)
|
super().__init__(parent)
|
||||||
self._base_url = base_url
|
self._base_url = base_url
|
||||||
|
|
||||||
def login_user(self, email: str, password: str):
|
def login_user(self, email: str, password: str):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import inspect
|
import inspect
|
||||||
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ class BaseCommand:
|
|||||||
def execute(self, client_response: dict = None):
|
def execute(self, client_response: dict = None):
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def htmx(self, target="this", swap="outerHTML", trigger=None):
|
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None):
|
||||||
# Note that the default value is the same than in get_htmx_params()
|
# Note that the default value is the same than in get_htmx_params()
|
||||||
if target is None:
|
if target is None:
|
||||||
self._htmx_extra["hx-swap"] = "none"
|
self._htmx_extra["hx-swap"] = "none"
|
||||||
@@ -97,6 +98,14 @@ class BaseCommand:
|
|||||||
def url(self):
|
def url(self):
|
||||||
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
||||||
|
|
||||||
|
def ajax_htmx_options(self):
|
||||||
|
return {
|
||||||
|
"url": self.url,
|
||||||
|
"target": self._htmx_extra.get("hx-target", "this"),
|
||||||
|
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
|
||||||
|
"values": {}
|
||||||
|
}
|
||||||
|
|
||||||
def get_ft(self):
|
def get_ft(self):
|
||||||
return self._ft
|
return self._ft
|
||||||
|
|
||||||
@@ -126,7 +135,7 @@ class Command(BaseCommand):
|
|||||||
def __init__(self, name, description, callback, *args, **kwargs):
|
def __init__(self, name, description, callback, *args, **kwargs):
|
||||||
super().__init__(name, description)
|
super().__init__(name, description)
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
self.callback_parameters = dict(inspect.signature(callback).parameters)
|
self.callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
|
||||||
self.args = args
|
self.args = args
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
|
|
||||||
@@ -141,8 +150,17 @@ class Command(BaseCommand):
|
|||||||
return float(value)
|
return float(value)
|
||||||
elif param.annotation == list:
|
elif param.annotation == list:
|
||||||
return value.split(",")
|
return value.split(",")
|
||||||
|
elif param.annotation == dict:
|
||||||
|
return json.loads(value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def ajax_htmx_options(self):
|
||||||
|
res = super().ajax_htmx_options()
|
||||||
|
if self.kwargs:
|
||||||
|
res["values"] |= self.kwargs
|
||||||
|
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
|
||||||
|
return res
|
||||||
|
|
||||||
def execute(self, client_response: dict = None):
|
def execute(self, client_response: dict = None):
|
||||||
ret_from_bindings = []
|
ret_from_bindings = []
|
||||||
|
|
||||||
@@ -180,6 +198,15 @@ class Command(BaseCommand):
|
|||||||
return [ret] + ret_from_bindings
|
return [ret] + ret_from_bindings
|
||||||
|
|
||||||
|
|
||||||
|
class LambdaCommand(Command):
|
||||||
|
def __init__(self, delegate, name="LambdaCommand", description="Lambda Command"):
|
||||||
|
super().__init__(name, description, delegate)
|
||||||
|
self.htmx(target=None)
|
||||||
|
|
||||||
|
def execute(self, client_response: dict = None):
|
||||||
|
return self.callback(client_response)
|
||||||
|
|
||||||
|
|
||||||
class CommandsManager:
|
class CommandsManager:
|
||||||
commands = {}
|
commands = {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,39 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
DEFAULT_COLUMN_WIDTH = 100
|
||||||
|
|
||||||
ROUTE_ROOT = "/myfasthtml"
|
ROUTE_ROOT = "/myfasthtml"
|
||||||
|
|
||||||
|
|
||||||
class Routes:
|
class Routes:
|
||||||
Commands = "/commands"
|
Commands = "/commands"
|
||||||
Bindings = "/bindings"
|
Bindings = "/bindings"
|
||||||
|
|
||||||
|
|
||||||
|
class ColumnType(Enum):
|
||||||
|
RowIndex = "RowIndex"
|
||||||
|
Text = "Text"
|
||||||
|
Number = "Number"
|
||||||
|
Datetime = "DateTime"
|
||||||
|
Bool = "Boolean"
|
||||||
|
Choice = "Choice"
|
||||||
|
List = "List"
|
||||||
|
|
||||||
|
|
||||||
|
class ViewType(Enum):
|
||||||
|
Table = "Table"
|
||||||
|
Chart = "Chart"
|
||||||
|
Form = "Form"
|
||||||
|
|
||||||
|
|
||||||
|
class FooterAggregation(Enum):
|
||||||
|
Sum = "Sum"
|
||||||
|
Mean = "Mean"
|
||||||
|
Min = "Min"
|
||||||
|
Max = "Max"
|
||||||
|
Count = "Count"
|
||||||
|
FilteredSum = "FilteredSum"
|
||||||
|
FilteredMean = "FilteredMean"
|
||||||
|
FilteredMin = "FilteredMin"
|
||||||
|
FilteredMax = "FilteredMax"
|
||||||
|
FilteredCount = "FilteredCount"
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
from dbengine.dbengine import DbEngine
|
from dbengine.dbengine import DbEngine
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
|
||||||
from myfasthtml.core.utils import retrieve_user_info
|
from myfasthtml.core.utils import retrieve_user_info
|
||||||
|
|
||||||
|
|
||||||
class DbManager(SingleInstance):
|
class DbManager(SingleInstance):
|
||||||
def __init__(self, session, parent=None, root=".myFastHtmlDb", auto_register: bool = True):
|
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
|
||||||
super().__init__(session, Ids.DbManager, parent, auto_register=auto_register)
|
super().__init__(parent, auto_register=auto_register)
|
||||||
|
|
||||||
self.db = DbEngine(root=root)
|
self.db = DbEngine(root=root)
|
||||||
|
|
||||||
def save(self, entry, obj):
|
def save(self, entry, obj):
|
||||||
@@ -35,12 +35,12 @@ class DbObject:
|
|||||||
It loads from DB at startup
|
It loads from DB at startup
|
||||||
"""
|
"""
|
||||||
_initializing = False
|
_initializing = False
|
||||||
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_session", "_forbidden_attrs"}
|
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
|
||||||
|
|
||||||
def __init__(self, session, name=None, db_manager=None):
|
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
|
||||||
self._session = session
|
self._owner = owner
|
||||||
self._name = name or self.__class__.__name__
|
self._name = name or owner.get_full_id()
|
||||||
self._db_manager = db_manager or InstancesManager.get(self._session, Ids.DbManager, DbManager)
|
self._db_manager = db_manager or DbManager(self._owner)
|
||||||
|
|
||||||
self._finalize_initialization()
|
self._finalize_initialization()
|
||||||
|
|
||||||
@@ -112,6 +112,7 @@ class DbObject:
|
|||||||
setattr(self, k, v)
|
setattr(self, k, v)
|
||||||
self._save_self()
|
self._save_self()
|
||||||
self._initializing = old_state
|
self._initializing = old_state
|
||||||
|
return self
|
||||||
|
|
||||||
def copy(self):
|
def copy(self):
|
||||||
as_dict = self._get_properties().copy()
|
as_dict = self._get_properties().copy()
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Self
|
from typing import Optional
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
from myfasthtml.controls.helpers import Ids
|
||||||
|
from myfasthtml.core.utils import pascal_to_snake
|
||||||
|
|
||||||
|
logger = logging.getLogger("InstancesManager")
|
||||||
|
|
||||||
special_session = {
|
special_session = {
|
||||||
"user_info": {"id": "** SPECIAL SESSION **"}
|
"user_info": {"id": "** SPECIAL SESSION **"}
|
||||||
@@ -18,25 +22,92 @@ class BaseInstance:
|
|||||||
Base class for all instances (manageable by InstancesManager)
|
Base class for all instances (manageable by InstancesManager)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session: dict, prefix: str, _id: str, parent: Self, auto_register: bool = True):
|
def __new__(cls, *args, **kwargs):
|
||||||
self._session = session
|
# Extract arguments from both positional and keyword arguments
|
||||||
self._id = _id
|
# Signature matches __init__: parent, session=None, _id=None, auto_register=True
|
||||||
self._prefix = prefix
|
parent = args[0] if len(args) > 0 and isinstance(args[0], BaseInstance) else kwargs.get("parent", None)
|
||||||
|
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None)
|
||||||
|
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
|
||||||
|
|
||||||
|
# Compute _id
|
||||||
|
_id = cls.compute_id(_id, parent)
|
||||||
|
|
||||||
|
if session is None:
|
||||||
|
if parent is not None:
|
||||||
|
session = parent.get_session()
|
||||||
|
else:
|
||||||
|
raise TypeError("Either session or parent must be provided")
|
||||||
|
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
key = (session_id, _id)
|
||||||
|
|
||||||
|
if key in InstancesManager.instances:
|
||||||
|
res = InstancesManager.instances[key]
|
||||||
|
if type(res) is not cls:
|
||||||
|
raise TypeError(f"Instance with id {_id} already exists, but is of type {type(res)}")
|
||||||
|
return res
|
||||||
|
|
||||||
|
# Otherwise create a new instance
|
||||||
|
instance = super().__new__(cls)
|
||||||
|
instance._is_new_instance = True # mark as fresh
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional['BaseInstance'],
|
||||||
|
session: Optional[dict] = None,
|
||||||
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
if not getattr(self, "_is_new_instance", False):
|
||||||
|
# Skip __init__ if instance already existed
|
||||||
|
return
|
||||||
|
elif not isinstance(self, UniqueInstance):
|
||||||
|
# No more __init__ unless it's UniqueInstance
|
||||||
|
self._is_new_instance = False
|
||||||
|
|
||||||
self._parent = parent
|
self._parent = parent
|
||||||
|
self._session = session or (parent.get_session() if parent else None)
|
||||||
|
self._id = self.compute_id(_id, parent)
|
||||||
|
self._prefix = self._id if isinstance(self, (UniqueInstance, SingleInstance)) else self.compute_prefix()
|
||||||
|
|
||||||
if auto_register:
|
if auto_register:
|
||||||
InstancesManager.register(session, self)
|
InstancesManager.register(self._session, self)
|
||||||
|
|
||||||
def get_id(self):
|
def get_session(self) -> dict:
|
||||||
return self._id
|
|
||||||
|
|
||||||
def get_session(self):
|
|
||||||
return self._session
|
return self._session
|
||||||
|
|
||||||
def get_prefix(self):
|
def get_id(self) -> str:
|
||||||
|
return self._id
|
||||||
|
|
||||||
|
def get_parent(self) -> Optional['BaseInstance']:
|
||||||
|
return self._parent
|
||||||
|
|
||||||
|
def get_prefix(self) -> str:
|
||||||
return self._prefix
|
return self._prefix
|
||||||
|
|
||||||
def get_parent(self):
|
def get_full_id(self) -> str:
|
||||||
return self._parent
|
return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
|
||||||
|
|
||||||
|
def get_full_parent_id(self) -> Optional[str]:
|
||||||
|
parent = self.get_parent()
|
||||||
|
return parent.get_full_id() if parent else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def compute_prefix(cls):
|
||||||
|
return f"mf-{pascal_to_snake(cls.__name__)}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def compute_id(cls, _id: Optional[str], parent: Optional['BaseInstance']):
|
||||||
|
if _id is None:
|
||||||
|
prefix = cls.compute_prefix()
|
||||||
|
if issubclass(cls, SingleInstance):
|
||||||
|
_id = prefix
|
||||||
|
else:
|
||||||
|
_id = f"{prefix}-{str(uuid.uuid4())}"
|
||||||
|
return _id
|
||||||
|
|
||||||
|
if _id.startswith("-") and parent is not None:
|
||||||
|
return f"{parent.get_prefix()}{_id}"
|
||||||
|
|
||||||
|
return _id
|
||||||
|
|
||||||
|
|
||||||
class SingleInstance(BaseInstance):
|
class SingleInstance(BaseInstance):
|
||||||
@@ -44,19 +115,26 @@ class SingleInstance(BaseInstance):
|
|||||||
Base class for instances that can only have one instance at a time.
|
Base class for instances that can only have one instance at a time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, session: dict, prefix: str, parent, auto_register: bool = True):
|
def __init__(self,
|
||||||
super().__init__(session, prefix, prefix, parent, auto_register)
|
parent: Optional[BaseInstance] = None,
|
||||||
|
session: Optional[dict] = None,
|
||||||
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
super().__init__(parent, session, _id, auto_register)
|
||||||
|
|
||||||
|
|
||||||
class UniqueInstance(BaseInstance):
|
class UniqueInstance(BaseInstance):
|
||||||
"""
|
"""
|
||||||
Base class for instances that can only have one instance at a time.
|
Base class for instances that can only have one instance at a time.
|
||||||
Does not throw exception if the instance already exists, it simply overwrites it.
|
But unlike SingleInstance, the __init__ is called every time it's instantiated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, prefix: str, parent: BaseInstance, auto_register: bool = True):
|
def __init__(self,
|
||||||
super().__init__(parent.get_session(), prefix, prefix, parent, auto_register)
|
parent: Optional[BaseInstance] = None,
|
||||||
self._prefix = prefix
|
session: Optional[dict] = None,
|
||||||
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
super().__init__(parent, session, _id, auto_register)
|
||||||
|
|
||||||
|
|
||||||
class MultipleInstance(BaseInstance):
|
class MultipleInstance(BaseInstance):
|
||||||
@@ -64,9 +142,11 @@ class MultipleInstance(BaseInstance):
|
|||||||
Base class for instances that can have multiple instances at a time.
|
Base class for instances that can have multiple instances at a time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, prefix: str, parent: BaseInstance, auto_register: bool = True, _id=None):
|
def __init__(self, parent: BaseInstance,
|
||||||
super().__init__(parent.get_session(), prefix, _id or f"{prefix}-{str(uuid.uuid4())}", parent, auto_register)
|
session: Optional[dict] = None,
|
||||||
self._prefix = prefix
|
_id: Optional[str] = None,
|
||||||
|
auto_register: bool = True):
|
||||||
|
super().__init__(parent, session, _id, auto_register)
|
||||||
|
|
||||||
|
|
||||||
class InstancesManager:
|
class InstancesManager:
|
||||||
@@ -80,7 +160,7 @@ class InstancesManager:
|
|||||||
:param instance:
|
:param instance:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
key = (InstancesManager._get_session_id(session), instance.get_id())
|
key = (InstancesManager.get_session_id(session), instance.get_id())
|
||||||
|
|
||||||
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
|
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
|
||||||
raise DuplicateInstanceError(instance)
|
raise DuplicateInstanceError(instance)
|
||||||
@@ -89,48 +169,61 @@ class InstancesManager:
|
|||||||
return instance
|
return instance
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get(session: dict, instance_id: str, instance_type: type = None, parent: BaseInstance = None, *args, **kwargs):
|
def get(session: dict, instance_id: str):
|
||||||
"""
|
"""
|
||||||
Get or create an instance of the given type (from its id)
|
Get or create an instance of the given type (from its id)
|
||||||
:param session:
|
:param session:
|
||||||
:param instance_id:
|
:param instance_id:
|
||||||
:param instance_type:
|
|
||||||
:param parent:
|
|
||||||
:param args:
|
|
||||||
:param kwargs:
|
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
try:
|
session_id = InstancesManager.get_session_id(session)
|
||||||
key = (InstancesManager._get_session_id(session), instance_id)
|
key = (session_id, instance_id)
|
||||||
|
|
||||||
return InstancesManager.instances[key]
|
return InstancesManager.instances[key]
|
||||||
except KeyError:
|
|
||||||
if instance_type:
|
|
||||||
if not issubclass(instance_type, SingleInstance):
|
|
||||||
assert parent is not None, "Parent instance must be provided if not SingleInstance"
|
|
||||||
|
|
||||||
if isinstance(parent, MultipleInstance):
|
|
||||||
return instance_type(parent, _id=instance_id, *args, **kwargs)
|
|
||||||
else:
|
|
||||||
return instance_type(session, parent=parent, *args, **kwargs) # it will be automatically registered
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_session_id(session):
|
def get_by_type(session: dict, cls: type):
|
||||||
if not session:
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
res = [i for s, i in InstancesManager.instances.items() if s[0] == session_id and isinstance(i, cls)]
|
||||||
|
assert len(res) <= 1, f"Multiple instances of type {cls.__name__} found"
|
||||||
|
assert len(res) > 0, f"No instance of type {cls.__name__} found"
|
||||||
|
return res[0]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_session_id(session):
|
||||||
|
if isinstance(session, str):
|
||||||
|
return session
|
||||||
|
if session is None:
|
||||||
return "** NOT LOGGED IN **"
|
return "** NOT LOGGED IN **"
|
||||||
if "user_info" not in session:
|
if "user_info" not in session:
|
||||||
return "** UNKNOWN USER **"
|
return "** UNKNOWN USER **"
|
||||||
return session["user_info"].get("id", "** INVALID SESSION **")
|
return session["user_info"].get("id", "** INVALID SESSION **")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_auth_proxy():
|
def get_session_user_name(session):
|
||||||
return InstancesManager.get(special_session, Ids.AuthProxy)
|
if session is None:
|
||||||
|
return "** NOT LOGGED IN **"
|
||||||
|
if "user_info" not in session:
|
||||||
|
return "** UNKNOWN USER **"
|
||||||
|
return session["user_info"].get("username", "** INVALID SESSION **")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def reset():
|
def reset():
|
||||||
return InstancesManager.instances.clear()
|
InstancesManager.instances.clear()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def clear_session(session):
|
||||||
|
"""
|
||||||
|
Remove all instances belonging to the given session.
|
||||||
|
:param session:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
|
||||||
|
InstancesManager.instances = {
|
||||||
|
key: instance
|
||||||
|
for key, instance in InstancesManager.instances.items()
|
||||||
|
if key[0] != session_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
RootInstance = SingleInstance(special_session, Ids.Root, None)
|
RootInstance = SingleInstance(None, special_session, Ids.Root)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import logging
|
|
||||||
|
|
||||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
|
||||||
from myfasthtml.controls.FileUpload import FileUpload
|
|
||||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
|
||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
|
||||||
from myfasthtml.controls.helpers import Ids
|
|
||||||
from myfasthtml.core.instances import BaseInstance, InstancesManager
|
|
||||||
|
|
||||||
logger = logging.getLogger("InstancesHelper")
|
|
||||||
|
|
||||||
|
|
||||||
class InstancesHelper:
|
|
||||||
@staticmethod
|
|
||||||
def dynamic_get(parent: BaseInstance, component_type: str, instance_id: str):
|
|
||||||
logger.debug(f"Dynamic get: {component_type} {instance_id}")
|
|
||||||
if component_type == Ids.VisNetwork:
|
|
||||||
return InstancesManager.get(parent.get_session(), instance_id,
|
|
||||||
VisNetwork, parent=parent, _id=instance_id)
|
|
||||||
elif component_type == Ids.InstancesDebugger:
|
|
||||||
return InstancesManager.get(parent.get_session(), instance_id,
|
|
||||||
InstancesDebugger, parent.get_session(), parent, instance_id)
|
|
||||||
elif component_type == Ids.CommandsDebugger:
|
|
||||||
return InstancesManager.get(parent.get_session(), instance_id,
|
|
||||||
CommandsDebugger, parent.get_session(), parent, instance_id)
|
|
||||||
elif component_type == Ids.FileUpload:
|
|
||||||
return InstancesManager.get(parent.get_session(), instance_id, FileUpload, parent)
|
|
||||||
logger.warning(f"Unknown component type: {component_type}")
|
|
||||||
return None
|
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
ROOT_COLOR = "#ff9999"
|
||||||
|
GHOST_COLOR = "#cccccc"
|
||||||
|
|
||||||
|
|
||||||
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
||||||
"""
|
"""
|
||||||
@@ -144,50 +147,34 @@ def from_tree_with_metadata(
|
|||||||
|
|
||||||
def from_parent_child_list(
|
def from_parent_child_list(
|
||||||
items: list,
|
items: list,
|
||||||
id_getter: callable = None,
|
id_getter: Callable = None,
|
||||||
label_getter: callable = None,
|
label_getter: Callable = None,
|
||||||
parent_getter: callable = None,
|
parent_getter: Callable = None,
|
||||||
ghost_color: str = "#ff9999"
|
ghost_color: str = GHOST_COLOR,
|
||||||
|
root_color: str | None = ROOT_COLOR
|
||||||
) -> tuple[list, list]:
|
) -> tuple[list, list]:
|
||||||
"""
|
"""
|
||||||
Convert a list of items with parent references to vis.js nodes and edges format.
|
Convert a list of items with parent references to vis.js nodes and edges format.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
items: List of items (dicts or objects) with parent references
|
items: List of items (dicts or objects) with parent references
|
||||||
(e.g., [{"id": "child", "parent": "root", "label": "Child"}, ...])
|
id_getter: callback to extract node ID
|
||||||
id_getter: Optional callback to extract node ID from item
|
label_getter: callback to extract node label
|
||||||
Default: lambda item: item.get("id")
|
parent_getter: callback to extract parent ID
|
||||||
label_getter: Optional callback to extract node label from item
|
ghost_color: color for ghost nodes (referenced parents)
|
||||||
Default: lambda item: item.get("label", "")
|
root_color: color for root nodes (nodes without parent)
|
||||||
parent_getter: Optional callback to extract parent ID from item
|
|
||||||
Default: lambda item: item.get("parent")
|
|
||||||
ghost_color: Color to use for ghost nodes (nodes referenced as parents but not in list)
|
|
||||||
Default: "#ff9999" (light red)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple: (nodes, edges) where:
|
tuple: (nodes, edges)
|
||||||
- nodes: list of dicts with IDs from items, ghost nodes have color property
|
|
||||||
- edges: list of dicts with 'from' and 'to' keys
|
|
||||||
|
|
||||||
Note:
|
|
||||||
- Nodes with parent=None or parent="" are treated as root nodes
|
|
||||||
- If a parent is referenced but doesn't exist in items, a ghost node is created
|
|
||||||
with the ghost_color applied
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> items = [
|
|
||||||
... {"id": "root", "label": "Root"},
|
|
||||||
... {"id": "child1", "parent": "root", "label": "Child 1"},
|
|
||||||
... {"id": "child2", "parent": "unknown", "label": "Child 2"}
|
|
||||||
... ]
|
|
||||||
>>> nodes, edges = from_parent_child_list(items)
|
|
||||||
>>> # "unknown" will be created as a ghost node with color="#ff9999"
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Default getters
|
# Default getters
|
||||||
if id_getter is None:
|
if id_getter is None:
|
||||||
id_getter = lambda item: item.get("id")
|
id_getter = lambda item: item.get("id")
|
||||||
|
|
||||||
if label_getter is None:
|
if label_getter is None:
|
||||||
label_getter = lambda item: item.get("label", "")
|
label_getter = lambda item: item.get("label", "")
|
||||||
|
|
||||||
if parent_getter is None:
|
if parent_getter is None:
|
||||||
parent_getter = lambda item: item.get("parent")
|
parent_getter = lambda item: item.get("parent")
|
||||||
|
|
||||||
@@ -205,34 +192,48 @@ def from_parent_child_list(
|
|||||||
existing_ids.add(node_id)
|
existing_ids.add(node_id)
|
||||||
nodes.append({
|
nodes.append({
|
||||||
"id": node_id,
|
"id": node_id,
|
||||||
"label": node_label
|
"label": node_label,
|
||||||
|
# root color assigned later
|
||||||
})
|
})
|
||||||
|
|
||||||
# Track ghost nodes to avoid duplicates
|
# Track ghost nodes
|
||||||
ghost_nodes = set()
|
ghost_nodes = set()
|
||||||
|
|
||||||
# Second pass: create edges and identify ghost nodes
|
# Track which nodes have parents
|
||||||
|
nodes_with_parent = set()
|
||||||
|
|
||||||
|
# Second pass: create edges and detect ghost nodes
|
||||||
for item in items:
|
for item in items:
|
||||||
node_id = id_getter(item)
|
node_id = id_getter(item)
|
||||||
parent_id = parent_getter(item)
|
parent_id = parent_getter(item)
|
||||||
|
|
||||||
# Skip if no parent or parent is empty string or None
|
# Skip roots
|
||||||
if parent_id is None or parent_id == "":
|
if parent_id is None or parent_id == "":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Create edge from parent to child
|
# Child has a parent
|
||||||
|
nodes_with_parent.add(node_id)
|
||||||
|
|
||||||
|
# Create edge parent → child
|
||||||
edges.append({
|
edges.append({
|
||||||
"from": parent_id,
|
"from": parent_id,
|
||||||
"to": node_id
|
"to": node_id
|
||||||
})
|
})
|
||||||
|
|
||||||
# Check if parent exists, if not create ghost node
|
# Create ghost node if parent not found
|
||||||
if parent_id not in existing_ids and parent_id not in ghost_nodes:
|
if parent_id not in existing_ids and parent_id not in ghost_nodes:
|
||||||
ghost_nodes.add(parent_id)
|
ghost_nodes.add(parent_id)
|
||||||
nodes.append({
|
nodes.append({
|
||||||
"id": parent_id,
|
"id": parent_id,
|
||||||
"label": str(parent_id), # Use ID as label for ghost nodes
|
"label": str(parent_id),
|
||||||
"color": ghost_color
|
"color": ghost_color
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Final pass: assign color to root nodes
|
||||||
|
if root_color is not None:
|
||||||
|
for node in nodes:
|
||||||
|
if node["id"] not in nodes_with_parent and node["id"] not in ghost_nodes:
|
||||||
|
# Root node
|
||||||
|
node["color"] = root_color
|
||||||
|
|
||||||
return nodes, edges
|
return nodes, edges
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from bs4 import Tag
|
from bs4 import Tag
|
||||||
from fastcore.xml import FT
|
from fastcore.xml import FT
|
||||||
@@ -234,6 +235,33 @@ def get_id(obj):
|
|||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
def pascal_to_snake(name: str) -> str:
|
||||||
|
"""Convert a PascalCase or CamelCase string to snake_case."""
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
|
# Insert underscore before capital letters (except the first one)
|
||||||
|
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||||
|
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
|
||||||
|
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
|
||||||
|
return s2.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def snake_to_pascal(name: str) -> str:
|
||||||
|
"""Convert a snake_case string to PascalCase."""
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Split on underscores and capitalize each part
|
||||||
|
parts = name.split('_')
|
||||||
|
return ''.join(word.capitalize() for word in parts if word)
|
||||||
|
|
||||||
|
|
||||||
@utils_rt(Routes.Commands)
|
@utils_rt(Routes.Commands)
|
||||||
def post(session, c_id: str, client_response: dict = None):
|
def post(session, c_id: str, client_response: dict = None):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import re
|
|||||||
|
|
||||||
def pascal_to_snake(name: str) -> str:
|
def pascal_to_snake(name: str) -> str:
|
||||||
"""Convert a PascalCase or CamelCase string to snake_case."""
|
"""Convert a PascalCase or CamelCase string to snake_case."""
|
||||||
|
if name is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = name.strip()
|
||||||
# Insert underscore before capital letters (except the first one)
|
# Insert underscore before capital letters (except the first one)
|
||||||
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||||
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
|
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from starlette.responses import Response
|
|||||||
from myfasthtml.auth.routes import setup_auth_routes
|
from myfasthtml.auth.routes import setup_auth_routes
|
||||||
from myfasthtml.auth.utils import create_auth_beforeware
|
from myfasthtml.auth.utils import create_auth_beforeware
|
||||||
from myfasthtml.core.AuthProxy import AuthProxy
|
from myfasthtml.core.AuthProxy import AuthProxy
|
||||||
|
from myfasthtml.core.instances import RootInstance
|
||||||
from myfasthtml.core.utils import utils_app
|
from myfasthtml.core.utils import utils_app
|
||||||
|
|
||||||
logger = logging.getLogger("MyFastHtml")
|
logger = logging.getLogger("MyFastHtml")
|
||||||
@@ -104,6 +105,6 @@ def create_app(daisyui: Optional[bool] = True,
|
|||||||
setup_auth_routes(app, rt, base_url=base_url)
|
setup_auth_routes(app, rt, base_url=base_url)
|
||||||
|
|
||||||
# create the AuthProxy instance
|
# create the AuthProxy instance
|
||||||
AuthProxy(base_url) # using the auto register mechanism to expose it
|
AuthProxy(RootInstance, base_url) # using the auto register mechanism to expose it
|
||||||
|
|
||||||
return app, rt
|
return app, rt
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
from fastcore.basics import NotStr
|
from fastcore.basics import NotStr
|
||||||
|
from fastcore.xml import FT
|
||||||
|
|
||||||
from myfasthtml.core.utils import quoted_str
|
from myfasthtml.core.commands import BaseCommand
|
||||||
|
from myfasthtml.core.utils import quoted_str, snake_to_pascal
|
||||||
from myfasthtml.test.testclient import MyFT
|
from myfasthtml.test.testclient import MyFT
|
||||||
|
|
||||||
|
MISSING_ATTR = "** MISSING **"
|
||||||
|
|
||||||
|
|
||||||
class Predicate:
|
class Predicate:
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
@@ -15,7 +20,18 @@ class Predicate:
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
|
if self.value is None:
|
||||||
|
str_value = ''
|
||||||
|
elif isinstance(self.value, str):
|
||||||
|
str_value = self.value
|
||||||
|
elif isinstance(self.value, (list, tuple)):
|
||||||
|
if len(self.value) == 1:
|
||||||
|
str_value = self.value[0]
|
||||||
|
else:
|
||||||
|
str_value = str(self.value)
|
||||||
|
else:
|
||||||
|
str_value = str(self.value)
|
||||||
|
return f"{self.__class__.__name__}({str_value})"
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
|
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
|
||||||
@@ -45,20 +61,33 @@ class StartsWith(AttrPredicate):
|
|||||||
return actual.startswith(self.value)
|
return actual.startswith(self.value)
|
||||||
|
|
||||||
|
|
||||||
class Contains(AttrPredicate):
|
class EndsWith(AttrPredicate):
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
super().__init__(value)
|
super().__init__(value)
|
||||||
|
|
||||||
def validate(self, actual):
|
def validate(self, actual):
|
||||||
return self.value in actual
|
return actual.endswith(self.value)
|
||||||
|
|
||||||
|
|
||||||
|
class Contains(AttrPredicate):
|
||||||
|
def __init__(self, *value, _word=False):
|
||||||
|
super().__init__(value)
|
||||||
|
self._word = _word
|
||||||
|
|
||||||
|
def validate(self, actual):
|
||||||
|
if self._word:
|
||||||
|
words = actual.split()
|
||||||
|
return all(val in words for val in self.value)
|
||||||
|
else:
|
||||||
|
return all(val in actual for val in self.value)
|
||||||
|
|
||||||
|
|
||||||
class DoesNotContain(AttrPredicate):
|
class DoesNotContain(AttrPredicate):
|
||||||
def __init__(self, value):
|
def __init__(self, *value):
|
||||||
super().__init__(value)
|
super().__init__(value)
|
||||||
|
|
||||||
def validate(self, actual):
|
def validate(self, actual):
|
||||||
return self.value not in actual
|
return all(val not in actual for val in self.value)
|
||||||
|
|
||||||
|
|
||||||
class AnyValue(AttrPredicate):
|
class AnyValue(AttrPredicate):
|
||||||
@@ -73,6 +102,14 @@ class AnyValue(AttrPredicate):
|
|||||||
return actual is not None
|
return actual is not None
|
||||||
|
|
||||||
|
|
||||||
|
class Regex(AttrPredicate):
|
||||||
|
def __init__(self, pattern):
|
||||||
|
super().__init__(pattern)
|
||||||
|
|
||||||
|
def validate(self, actual):
|
||||||
|
return re.match(self.value, actual) is not None
|
||||||
|
|
||||||
|
|
||||||
class ChildrenPredicate(Predicate):
|
class ChildrenPredicate(Predicate):
|
||||||
"""
|
"""
|
||||||
Predicate given as a child of an element.
|
Predicate given as a child of an element.
|
||||||
@@ -114,11 +151,142 @@ class AttributeForbidden(ChildrenPredicate):
|
|||||||
return element
|
return element
|
||||||
|
|
||||||
|
|
||||||
|
class HasHtmx(ChildrenPredicate):
|
||||||
|
def __init__(self, command: BaseCommand = None, **htmx_params):
|
||||||
|
super().__init__(None)
|
||||||
|
self.command = command
|
||||||
|
if command:
|
||||||
|
self.htmx_params = command.get_htmx_params() | htmx_params
|
||||||
|
else:
|
||||||
|
self.htmx_params = htmx_params
|
||||||
|
|
||||||
|
self.htmx_params = {k.replace("hx_", "hx-"): v for k, v in self.htmx_params.items()}
|
||||||
|
|
||||||
|
def validate(self, actual):
|
||||||
|
return all(actual.attrs.get(k) == v for k, v in self.htmx_params.items())
|
||||||
|
|
||||||
|
def to_debug(self, element):
|
||||||
|
for k, v in self.htmx_params.items():
|
||||||
|
element.attrs[k] = v
|
||||||
|
return element
|
||||||
|
|
||||||
|
|
||||||
|
class TestObject:
|
||||||
|
def __init__(self, cls, **kwargs):
|
||||||
|
self.cls = cls
|
||||||
|
self.attrs = kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class TestIcon(TestObject):
|
||||||
|
def __init__(self, name: Optional[str] = '', command=None):
|
||||||
|
super().__init__("div")
|
||||||
|
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
||||||
|
self.children = [
|
||||||
|
TestObject(NotStr, s=Regex(f'<svg name="\\w+-{self.name}'))
|
||||||
|
]
|
||||||
|
if command:
|
||||||
|
self.attrs |= command.get_htmx_params()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'<div><svg name="{self.name}" .../></div>'
|
||||||
|
|
||||||
|
|
||||||
|
class TestIconNotStr(TestObject):
|
||||||
|
def __init__(self, name: Optional[str] = ''):
|
||||||
|
super().__init__(NotStr)
|
||||||
|
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
||||||
|
self.attrs["s"] = Regex(f'<svg name="\\w+-{self.name}')
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'<svg name="{self.name}" .../>'
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommand(TestObject):
|
||||||
|
def __init__(self, name, **kwargs):
|
||||||
|
super().__init__("Command", **kwargs)
|
||||||
|
self.attrs = {"name": name} | kwargs # name should be first
|
||||||
|
|
||||||
|
|
||||||
|
class TestScript(TestObject):
|
||||||
|
def __init__(self, script):
|
||||||
|
super().__init__("script")
|
||||||
|
self.script = script
|
||||||
|
self.children = [
|
||||||
|
NotStr(self.script),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DoNotCheck:
|
class DoNotCheck:
|
||||||
desc: str = None
|
desc: str = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Skip:
|
||||||
|
element: Any
|
||||||
|
desc: str = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_type(x):
|
||||||
|
if hasattr(x, "tag"):
|
||||||
|
return x.tag
|
||||||
|
if isinstance(x, (TestObject, TestIcon)):
|
||||||
|
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
||||||
|
return type(x).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def _get_attr(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 _get_attributes(x):
|
||||||
|
"""Get the attributes dict from an element."""
|
||||||
|
if hasattr(x, "attrs"):
|
||||||
|
return x.attrs
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_children(x):
|
||||||
|
"""Get the children list from an element."""
|
||||||
|
if hasattr(x, "children"):
|
||||||
|
return x.children
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _str_element(element, expected=None, keep_open=None):
|
||||||
|
# compare to itself if no expected element is provided
|
||||||
|
if expected is None:
|
||||||
|
expected = element
|
||||||
|
|
||||||
|
if hasattr(element, "tag"):
|
||||||
|
# the attributes are compared to the expected element
|
||||||
|
elt_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in
|
||||||
|
[attr_name for attr_name in _get_attributes(expected) if attr_name is not None]}
|
||||||
|
|
||||||
|
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
|
||||||
|
tag_str = f"({element.tag} {elt_attrs_str}"
|
||||||
|
|
||||||
|
# manage the closing tag
|
||||||
|
if keep_open is False:
|
||||||
|
tag_str += " ...)" if len(element.children) > 0 else ")"
|
||||||
|
elif keep_open is True:
|
||||||
|
tag_str += "..." if elt_attrs_str == "" else " ..."
|
||||||
|
else:
|
||||||
|
# close the tag if there are no children
|
||||||
|
not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
|
||||||
|
if len(not_special_children) == 0: tag_str += ")"
|
||||||
|
return tag_str
|
||||||
|
|
||||||
|
else:
|
||||||
|
return quoted_str(element)
|
||||||
|
|
||||||
|
|
||||||
class ErrorOutput:
|
class ErrorOutput:
|
||||||
def __init__(self, path, element, expected):
|
def __init__(self, path, element, expected):
|
||||||
self.path = path
|
self.path = path
|
||||||
@@ -143,14 +311,14 @@ class ErrorOutput:
|
|||||||
# first render the path hierarchy
|
# first render the path hierarchy
|
||||||
for p in self.path.split(".")[:-1]:
|
for p in self.path.split(".")[:-1]:
|
||||||
elt_name, attr_name, attr_value = self._unconstruct_path_item(p)
|
elt_name, attr_name, attr_value = self._unconstruct_path_item(p)
|
||||||
path_str = self._str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
|
path_str = _str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
|
||||||
self._add_to_output(f"{path_str}")
|
self._add_to_output(f"{path_str}")
|
||||||
self.indent += " "
|
self.indent += " "
|
||||||
|
|
||||||
# then render the element
|
# then render the element
|
||||||
if hasattr(self.expected, "tag") and hasattr(self.element, "tag"):
|
if hasattr(self.expected, "tag") and hasattr(self.element, "tag"):
|
||||||
# display the tag and its attributes
|
# display the tag and its attributes
|
||||||
tag_str = self._str_element(self.element, self.expected)
|
tag_str = _str_element(self.element, self.expected)
|
||||||
self._add_to_output(tag_str)
|
self._add_to_output(tag_str)
|
||||||
|
|
||||||
# Try to show where the differences are
|
# Try to show where the differences are
|
||||||
@@ -173,7 +341,7 @@ class ErrorOutput:
|
|||||||
|
|
||||||
# display the child
|
# display the child
|
||||||
element_child = self.element.children[element_index]
|
element_child = self.element.children[element_index]
|
||||||
child_str = self._str_element(element_child, expected_child, keep_open=False)
|
child_str = _str_element(element_child, expected_child, keep_open=False)
|
||||||
self._add_to_output(child_str)
|
self._add_to_output(child_str)
|
||||||
|
|
||||||
# manage errors (only when the expected is a FT element
|
# manage errors (only when the expected is a FT element
|
||||||
@@ -187,6 +355,16 @@ class ErrorOutput:
|
|||||||
|
|
||||||
self.indent = self.indent[:-2]
|
self.indent = self.indent[:-2]
|
||||||
self._add_to_output(")")
|
self._add_to_output(")")
|
||||||
|
|
||||||
|
elif isinstance(self.expected, TestObject):
|
||||||
|
cls = _get_type(self.element)
|
||||||
|
attrs = {attr_name: _get_attr(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(self.element, self.expected)
|
||||||
|
if error_str:
|
||||||
|
self._add_to_output(error_str)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self._add_to_output(str(self.element))
|
self._add_to_output(str(self.element))
|
||||||
# Try to show where the differences are
|
# Try to show where the differences are
|
||||||
@@ -197,49 +375,35 @@ class ErrorOutput:
|
|||||||
def _add_to_output(self, msg):
|
def _add_to_output(self, msg):
|
||||||
self.output.append(f"{self.indent}{msg}")
|
self.output.append(f"{self.indent}{msg}")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _str_element(element, expected=None, keep_open=None):
|
|
||||||
# compare to itself if no expected element is provided
|
|
||||||
if expected is None:
|
|
||||||
expected = element
|
|
||||||
|
|
||||||
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
|
|
||||||
[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())
|
|
||||||
tag_str = f"({element.tag} {elt_attrs_str}"
|
|
||||||
|
|
||||||
# manage the closing tag
|
|
||||||
if keep_open is False:
|
|
||||||
tag_str += " ...)" if len(element.children) > 0 else ")"
|
|
||||||
elif keep_open is True:
|
|
||||||
tag_str += "..." if elt_attrs_str == "" else " ..."
|
|
||||||
else:
|
|
||||||
# close the tag if there are no children
|
|
||||||
not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
|
|
||||||
if len(not_special_children) == 0: tag_str += ")"
|
|
||||||
return tag_str
|
|
||||||
|
|
||||||
else:
|
|
||||||
return quoted_str(element)
|
|
||||||
|
|
||||||
def _detect_error(self, element, expected):
|
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 "^")
|
Detect errors between element and expected, returning a visual marker string.
|
||||||
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in expected.attrs}
|
Unified version that handles both FT elements and TestObjects.
|
||||||
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
|
"""
|
||||||
not self._matches(attr_value, expected.attrs[attr_name])]
|
# For elements with structure (FT or TestObject)
|
||||||
if attrs_in_error:
|
if hasattr(expected, "tag") or isinstance(expected, TestObject):
|
||||||
elt_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
element_type = _get_type(element)
|
||||||
for name, value in elt_attrs.items())
|
expected_type = _get_type(expected)
|
||||||
error_str = f" {tag_str} {elt_attrs_error}"
|
type_error = (" " if element_type == expected_type else "^") * len(element_type)
|
||||||
return error_str
|
|
||||||
|
element_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in _get_attributes(expected)}
|
||||||
|
expected_attrs = {attr_name: _get_attr(expected, attr_name) for attr_name in _get_attributes(expected)}
|
||||||
|
attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
|
||||||
|
not self._matches(attr_value, expected_attrs[attr_name])}
|
||||||
|
|
||||||
|
attrs_error = " ".join(
|
||||||
|
len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||||
|
for name, value in element_attrs.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
if type_error.strip() or attrs_error.strip():
|
||||||
|
return f" {type_error} {attrs_error}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
# For simple values
|
||||||
else:
|
else:
|
||||||
if not self._matches(element, expected):
|
if not self._matches(element, expected):
|
||||||
return len(str(element)) * "^"
|
return len(str(element)) * "^"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -290,39 +454,221 @@ class ErrorComparisonOutput:
|
|||||||
return "\n".join(output)
|
return "\n".join(output)
|
||||||
|
|
||||||
|
|
||||||
def matches(actual, expected, path=""):
|
class Matcher:
|
||||||
def print_path(p):
|
"""
|
||||||
return f"Path : '{p}'\n" if p else ""
|
Matcher class for comparing actual and expected elements.
|
||||||
|
Provides flexible comparison with support for predicates, nested structures,
|
||||||
|
and detailed error reporting.
|
||||||
|
"""
|
||||||
|
|
||||||
def _type(x):
|
def __init__(self):
|
||||||
return type(x)
|
self.path = ""
|
||||||
|
|
||||||
def _debug(elt):
|
def matches(self, actual, expected):
|
||||||
return str(elt) if elt else "None"
|
"""
|
||||||
|
Compare actual and expected elements.
|
||||||
|
|
||||||
def _debug_compare(a, b):
|
Args:
|
||||||
actual_out = ErrorOutput(path, a, b)
|
actual: The actual element to compare
|
||||||
expected_out = ErrorOutput(path, b, b)
|
expected: The expected element or pattern
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if elements match, raises AssertionError otherwise
|
||||||
|
"""
|
||||||
|
if actual is not None and expected is None:
|
||||||
|
self._assert_error("Actual is not None while expected is None", _actual=actual)
|
||||||
|
|
||||||
|
if isinstance(expected, DoNotCheck):
|
||||||
|
return True
|
||||||
|
|
||||||
|
if actual is None and expected is not None:
|
||||||
|
self._assert_error("Actual is None while expected is ", _expected=expected)
|
||||||
|
|
||||||
|
# set the path
|
||||||
|
current_path = self._get_current_path(actual)
|
||||||
|
original_path = self.path
|
||||||
|
self.path = self.path + "." + current_path if self.path else current_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._match_elements(actual, expected)
|
||||||
|
finally:
|
||||||
|
# restore the original path for sibling comparisons
|
||||||
|
self.path = original_path
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _match_elements(self, actual, expected):
|
||||||
|
"""Internal method that performs the actual comparison logic."""
|
||||||
|
if isinstance(expected, TestObject) or hasattr(expected, "tag"):
|
||||||
|
self._match_element(actual, expected)
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(expected, Predicate):
|
||||||
|
assert expected.validate(actual), \
|
||||||
|
self._error_msg(f"The condition '{expected}' is not satisfied.",
|
||||||
|
_actual=actual,
|
||||||
|
_expected=expected)
|
||||||
|
return
|
||||||
|
|
||||||
|
assert _get_type(actual) == _get_type(expected), \
|
||||||
|
self._error_msg("The types are different.", _actual=actual, _expected=expected)
|
||||||
|
|
||||||
|
if isinstance(expected, (list, tuple)):
|
||||||
|
self._match_list(actual, expected)
|
||||||
|
elif isinstance(expected, dict):
|
||||||
|
self._match_dict(actual, expected)
|
||||||
|
elif isinstance(expected, NotStr):
|
||||||
|
self._match_notstr(actual, expected)
|
||||||
|
else:
|
||||||
|
assert actual == expected, self._error_msg("The values are different",
|
||||||
|
_actual=actual,
|
||||||
|
_expected=expected)
|
||||||
|
|
||||||
|
def _match_element(self, actual, expected):
|
||||||
|
"""Match a TestObject or FT element."""
|
||||||
|
# Validate the type/tag
|
||||||
|
assert _get_type(actual) == _get_type(expected), \
|
||||||
|
self._error_msg("The types are different.", _actual=_get_type(actual), _expected=_get_type(expected))
|
||||||
|
|
||||||
|
# Special conditions (ChildrenPredicate)
|
||||||
|
expected_children = _get_children(expected)
|
||||||
|
for predicate in [c for c in expected_children if isinstance(c, ChildrenPredicate)]:
|
||||||
|
assert predicate.validate(actual), \
|
||||||
|
self._error_msg(f"The condition '{predicate}' is not satisfied.",
|
||||||
|
_actual=actual,
|
||||||
|
_expected=predicate.to_debug(expected))
|
||||||
|
|
||||||
|
# Compare the attributes
|
||||||
|
expected_attrs = _get_attributes(expected)
|
||||||
|
for expected_attr, expected_value in expected_attrs.items():
|
||||||
|
actual_value = _get_attr(actual, expected_attr)
|
||||||
|
|
||||||
|
# Check if attribute exists
|
||||||
|
if actual_value == MISSING_ATTR:
|
||||||
|
self._assert_error(f"'{expected_attr}' is not found in Actual. (attributes: {self._str_attrs(actual)})",
|
||||||
|
_actual=actual,
|
||||||
|
_expected=expected)
|
||||||
|
|
||||||
|
# Handle Predicate values
|
||||||
|
if isinstance(expected_value, Predicate):
|
||||||
|
assert expected_value.validate(actual_value), \
|
||||||
|
self._error_msg(f"The condition '{expected_value}' is not satisfied.",
|
||||||
|
_actual=actual,
|
||||||
|
_expected=expected)
|
||||||
|
|
||||||
|
# Handle TestObject recursive matching
|
||||||
|
elif isinstance(expected, TestObject):
|
||||||
|
try:
|
||||||
|
self.matches(actual_value, expected_value)
|
||||||
|
except AssertionError as e:
|
||||||
|
match = re.search(r"Error : (.+?)\n", str(e))
|
||||||
|
if match:
|
||||||
|
self._assert_error(f"{match.group(1)} for '{expected_attr}'.",
|
||||||
|
_actual=actual_value,
|
||||||
|
_expected=expected_value)
|
||||||
|
else:
|
||||||
|
self._assert_error(f"The values are different for '{expected_attr}'.",
|
||||||
|
_actual=actual_value,
|
||||||
|
_expected=expected_value)
|
||||||
|
|
||||||
|
# Handle regular value comparison
|
||||||
|
else:
|
||||||
|
assert actual_value == expected_value, \
|
||||||
|
self._error_msg(f"The values are different for '{expected_attr}'.",
|
||||||
|
_actual=actual,
|
||||||
|
_expected=expected)
|
||||||
|
|
||||||
|
# Compare the children (only if present)
|
||||||
|
if expected_children:
|
||||||
|
# Filter out Predicate children
|
||||||
|
expected_children = [c for c in expected_children if not isinstance(c, Predicate)]
|
||||||
|
actual_children = _get_children(actual)
|
||||||
|
|
||||||
|
if len(actual_children) < len(expected_children):
|
||||||
|
self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected)
|
||||||
|
|
||||||
|
actual_child_index, expected_child_index = 0, 0
|
||||||
|
while expected_child_index < len(expected_children):
|
||||||
|
if actual_child_index >= len(actual_children):
|
||||||
|
self._assert_error("Nothing more to skip.", _actual=actual, _expected=expected)
|
||||||
|
|
||||||
|
actual_child = actual_children[actual_child_index]
|
||||||
|
expected_child = expected_children[expected_child_index]
|
||||||
|
|
||||||
|
if isinstance(expected_child, Skip):
|
||||||
|
try:
|
||||||
|
# if this is the element to skip, skip it and continue
|
||||||
|
self._match_element(actual_child, expected_child.element)
|
||||||
|
actual_child_index += 1
|
||||||
|
continue
|
||||||
|
except AssertionError:
|
||||||
|
# otherwise try to match with the following element
|
||||||
|
expected_child_index += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
assert self.matches(actual_child, expected_child)
|
||||||
|
|
||||||
|
actual_child_index += 1
|
||||||
|
expected_child_index += 1
|
||||||
|
|
||||||
|
def _match_list(self, actual, expected):
|
||||||
|
"""Match list or tuple."""
|
||||||
|
if len(actual) < len(expected):
|
||||||
|
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||||
|
if len(actual) > len(expected):
|
||||||
|
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||||
|
|
||||||
|
for actual_child, expected_child in zip(actual, expected):
|
||||||
|
assert self.matches(actual_child, expected_child)
|
||||||
|
|
||||||
|
def _match_dict(self, actual, expected):
|
||||||
|
"""Match dictionary."""
|
||||||
|
if len(actual) < len(expected):
|
||||||
|
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||||
|
if len(actual) > len(expected):
|
||||||
|
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||||
|
for k, v in expected.items():
|
||||||
|
assert self.matches(actual[k], v)
|
||||||
|
|
||||||
|
def _match_notstr(self, actual, expected):
|
||||||
|
"""Match NotStr type."""
|
||||||
|
to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
|
||||||
|
assert to_compare.startswith(expected.s), self._error_msg("Notstr values are different: ",
|
||||||
|
_actual=to_compare,
|
||||||
|
_expected=expected.s)
|
||||||
|
|
||||||
|
def _print_path(self):
|
||||||
|
"""Format the current path for error messages."""
|
||||||
|
return f"Path : '{self.path}'\n" if self.path else ""
|
||||||
|
|
||||||
|
def _debug_compare(self, a, b):
|
||||||
|
"""Generate a comparison debug output."""
|
||||||
|
actual_out = ErrorOutput(self.path, a, b)
|
||||||
|
expected_out = ErrorOutput(self.path, b, b)
|
||||||
|
|
||||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||||
return comparison_out.render()
|
return comparison_out.render()
|
||||||
|
|
||||||
def _error_msg(msg, _actual=None, _expected=None):
|
def _error_msg(self, msg, _actual=None, _expected=None):
|
||||||
|
"""Generate an error message with debug information."""
|
||||||
if _actual is None and _expected is None:
|
if _actual is None and _expected is None:
|
||||||
debug_info = ""
|
debug_info = ""
|
||||||
elif _actual is None:
|
elif _actual is None:
|
||||||
debug_info = _debug(_expected)
|
debug_info = self._debug(_expected)
|
||||||
elif _expected is None:
|
elif _expected is None:
|
||||||
debug_info = _debug(_actual)
|
debug_info = self._debug(_actual)
|
||||||
else:
|
else:
|
||||||
debug_info = _debug_compare(_actual, _expected)
|
debug_info = self._debug_compare(_actual, _expected)
|
||||||
|
|
||||||
return f"{print_path(path)}Error : {msg}\n{debug_info}"
|
return f"{self._print_path()}Error : {msg}\n{debug_info}"
|
||||||
|
|
||||||
def _assert_error(msg, _actual=None, _expected=None):
|
def _assert_error(self, msg, _actual=None, _expected=None):
|
||||||
assert False, _error_msg(msg, _actual=_actual, _expected=_expected)
|
"""Raise an assertion error with formatted message."""
|
||||||
|
assert False, self._error_msg(msg, _actual=_actual, _expected=_expected)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
def _get_current_path(elt):
|
def _get_current_path(elt):
|
||||||
|
"""Get the path representation of an element."""
|
||||||
if hasattr(elt, "tag"):
|
if hasattr(elt, "tag"):
|
||||||
res = f"{elt.tag}"
|
res = f"{elt.tag}"
|
||||||
if "id" in elt.attrs:
|
if "id" in elt.attrs:
|
||||||
@@ -335,134 +681,128 @@ def matches(actual, expected, path=""):
|
|||||||
else:
|
else:
|
||||||
return elt.__class__.__name__
|
return elt.__class__.__name__
|
||||||
|
|
||||||
if actual is not None and expected is None:
|
@staticmethod
|
||||||
_assert_error("Actual is not None while expected is None", _actual=actual)
|
def _str_attrs(element):
|
||||||
|
"""Format attributes as a string."""
|
||||||
|
attrs = _get_attributes(element)
|
||||||
|
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
|
||||||
|
|
||||||
if isinstance(expected, DoNotCheck):
|
@staticmethod
|
||||||
return True
|
def _debug(elt):
|
||||||
|
"""Format an element for debug output."""
|
||||||
|
return _str_element(elt, keep_open=False) if elt else "None"
|
||||||
|
|
||||||
if actual is None and expected is not None:
|
|
||||||
_assert_error("Actual is None while expected is ", _expected=expected)
|
|
||||||
|
|
||||||
# set the path
|
def matches(actual, expected, path=""):
|
||||||
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
|
"""
|
||||||
|
Compare actual and expected elements.
|
||||||
|
|
||||||
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
|
This is a convenience wrapper around the Matcher class.
|
||||||
_error_msg("The types are different: ", _actual=actual, _expected=expected)
|
|
||||||
|
|
||||||
if isinstance(expected, (list, tuple)):
|
Args:
|
||||||
if len(actual) < len(expected):
|
actual: The actual element to compare
|
||||||
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
expected: The expected element or pattern
|
||||||
if len(actual) > len(expected):
|
path: Optional initial path for error reporting
|
||||||
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
|
||||||
|
|
||||||
for actual_child, expected_child in zip(actual, expected):
|
Returns:
|
||||||
assert matches(actual_child, expected_child, path=path)
|
True if elements match, raises AssertionError otherwise
|
||||||
|
"""
|
||||||
elif isinstance(expected, NotStr):
|
matcher = Matcher()
|
||||||
to_compare = actual.s.lstrip('\n').lstrip()
|
matcher.path = path
|
||||||
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
|
return matcher.matches(actual, expected)
|
||||||
_actual=to_compare,
|
|
||||||
_expected=expected.s)
|
|
||||||
|
|
||||||
elif hasattr(expected, "tag"):
|
|
||||||
# validate the tags names
|
|
||||||
assert actual.tag == expected.tag, _error_msg("The elements are different.",
|
|
||||||
_actual=actual.tag,
|
|
||||||
_expected=expected.tag)
|
|
||||||
|
|
||||||
# special conditions
|
|
||||||
for predicate in [c for c in expected.children if isinstance(c, ChildrenPredicate)]:
|
|
||||||
assert predicate.validate(actual), \
|
|
||||||
_error_msg(f"The condition '{predicate}' is not satisfied.",
|
|
||||||
_actual=actual,
|
|
||||||
_expected=predicate.to_debug(expected))
|
|
||||||
|
|
||||||
# compare the attributes
|
|
||||||
for expected_attr, expected_value in expected.attrs.items():
|
|
||||||
assert expected_attr in actual.attrs, _error_msg(f"'{expected_attr}' is not found in Actual.",
|
|
||||||
_actual=actual,
|
|
||||||
_expected=expected)
|
|
||||||
|
|
||||||
if isinstance(expected_value, Predicate):
|
|
||||||
assert expected_value.validate(actual.attrs[expected_attr]), \
|
|
||||||
_error_msg(f"The condition '{expected_value}' is not satisfied.",
|
|
||||||
_actual=actual,
|
|
||||||
_expected=expected)
|
|
||||||
|
|
||||||
else:
|
|
||||||
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
|
|
||||||
_error_msg(f"The values are different for '{expected_attr}': ",
|
|
||||||
_actual=actual,
|
|
||||||
_expected=expected)
|
|
||||||
|
|
||||||
# compare the children
|
|
||||||
expected_children = [c for c in expected.children if not isinstance(c, Predicate)]
|
|
||||||
if len(actual.children) < len(expected_children):
|
|
||||||
_assert_error("Actual is lesser than expected: ", _actual=actual, _expected=expected)
|
|
||||||
|
|
||||||
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: ",
|
|
||||||
_actual=actual,
|
|
||||||
_expected=expected)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def find(ft, expected):
|
def find(ft, expected):
|
||||||
res = []
|
"""
|
||||||
|
Find all occurrences of an expected element within a FastHTML tree.
|
||||||
|
|
||||||
def _type(x):
|
Args:
|
||||||
return type(x)
|
ft: A FastHTML element or list of elements to search in
|
||||||
|
expected: The element pattern to find
|
||||||
|
|
||||||
def _same(_ft, _expected):
|
Returns:
|
||||||
if _ft == _expected:
|
List of matching elements
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AssertionError: If no matching elements are found
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _elements_match(actual, expected):
|
||||||
|
"""Check if two elements are the same based on tag, attributes, and children."""
|
||||||
|
if isinstance(expected, DoNotCheck):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if _ft.tag != _expected.tag:
|
if isinstance(expected, NotStr):
|
||||||
|
to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
|
||||||
|
return to_compare.startswith(expected.s)
|
||||||
|
|
||||||
|
if isinstance(actual, NotStr) and _get_type(actual) != _get_type(expected):
|
||||||
|
return False # to manage the unexpected __eq__ behavior of NotStr
|
||||||
|
|
||||||
|
if not isinstance(expected, (TestObject, FT)):
|
||||||
|
return actual == expected
|
||||||
|
|
||||||
|
# Compare tags
|
||||||
|
if _get_type(actual) != _get_type(expected):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
for attr in _expected.attrs:
|
# Compare attributes
|
||||||
if attr not in _ft.attrs or _ft.attrs[attr] != _expected.attrs[attr]:
|
expected_attrs = _get_attributes(expected)
|
||||||
|
for attr_name, expected_attr_value in expected_attrs.items():
|
||||||
|
actual_attr_value = _get_attr(actual, attr_name)
|
||||||
|
# attribute is missing
|
||||||
|
if actual_attr_value == MISSING_ATTR:
|
||||||
return False
|
return False
|
||||||
|
# manage predicate values
|
||||||
|
if isinstance(expected_attr_value, Predicate):
|
||||||
|
return expected_attr_value.validate(actual_attr_value)
|
||||||
|
# finally compare values
|
||||||
|
return actual_attr_value == expected_attr_value
|
||||||
|
|
||||||
for expected_child in _expected.children:
|
# Compare children recursively
|
||||||
for ft_child in _ft.children:
|
expected_children = _get_children(expected)
|
||||||
if _same(ft_child, expected_child):
|
actual_children = _get_children(actual)
|
||||||
break
|
|
||||||
else:
|
for expected_child in expected_children:
|
||||||
|
# Check if this expected child exists somewhere in actual children
|
||||||
|
if not any(_elements_match(actual_child, expected_child) for actual_child in actual_children):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _find(current, current_expected):
|
def _search_tree(current, pattern):
|
||||||
|
"""Recursively search for pattern in the tree rooted at current."""
|
||||||
|
# Check if current element matches
|
||||||
|
matches = []
|
||||||
|
if _elements_match(current, pattern):
|
||||||
|
matches.append(current)
|
||||||
|
|
||||||
if _type(current) != _type(current_expected):
|
# Recursively search in children, in the case that the pattern also appears in children
|
||||||
return []
|
for child in _get_children(current):
|
||||||
|
matches.extend(_search_tree(child, pattern))
|
||||||
|
|
||||||
if not hasattr(current, "tag"):
|
return matches
|
||||||
return [current] if current == current_expected else []
|
|
||||||
|
|
||||||
_found = []
|
# Normalize input to list
|
||||||
if _same(current, current_expected):
|
elements_to_search = ft if isinstance(ft, (list, tuple, set)) else [ft]
|
||||||
_found.append(current)
|
|
||||||
|
|
||||||
# look at the children
|
# Search in all provided elements
|
||||||
for child in current.children:
|
all_matches = []
|
||||||
_found.extend(_find(child, current_expected))
|
for element in elements_to_search:
|
||||||
|
all_matches.extend(_search_tree(element, expected))
|
||||||
|
|
||||||
return _found
|
# Raise error if nothing found
|
||||||
|
if not all_matches:
|
||||||
ft_as_list = [ft] if not isinstance(ft, (list, tuple, set)) else ft
|
|
||||||
|
|
||||||
for current_ft in ft_as_list:
|
|
||||||
found = _find(current_ft, expected)
|
|
||||||
res.extend(found)
|
|
||||||
|
|
||||||
if len(res) == 0:
|
|
||||||
raise AssertionError(f"No element found for '{expected}'")
|
raise AssertionError(f"No element found for '{expected}'")
|
||||||
|
|
||||||
return res
|
return all_matches
|
||||||
|
|
||||||
|
|
||||||
|
def find_one(ft, expected):
|
||||||
|
found = find(ft, expected)
|
||||||
|
assert len(found) == 1, f"Found {len(found)} elements for '{expected}'"
|
||||||
|
return found[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _str_attrs(attrs: dict):
|
||||||
|
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from myfasthtml.core.instances import SingleInstance
|
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||||
|
|
||||||
|
|
||||||
|
class RootInstanceForTests(SingleInstance):
|
||||||
|
def __init__(self, session):
|
||||||
|
super().__init__(None, session, _id="TestRoot")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -20,4 +25,5 @@ def session():
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def root_instance(session):
|
def root_instance(session):
|
||||||
return SingleInstance(session, "TestRoot", None)
|
InstancesManager.reset()
|
||||||
|
return RootInstanceForTests(session=session)
|
||||||
|
|||||||
677
tests/controls/test_layout.py
Normal file
677
tests/controls/test_layout.py
Normal file
@@ -0,0 +1,677 @@
|
|||||||
|
"""Unit tests for Layout component."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fasthtml.components import *
|
||||||
|
|
||||||
|
from myfasthtml.controls.Layout import Layout
|
||||||
|
from myfasthtml.controls.UserProfile import UserProfile
|
||||||
|
from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript, TestObject, AnyValue, Skip, \
|
||||||
|
TestIconNotStr
|
||||||
|
from .conftest import root_instance
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def cleanup_db():
|
||||||
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayoutBehaviour:
|
||||||
|
"""Tests for Layout behavior and logic."""
|
||||||
|
|
||||||
|
def test_i_can_create_layout(self, root_instance):
|
||||||
|
"""Test basic layout creation with app_name."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
|
||||||
|
assert layout is not None
|
||||||
|
assert layout.app_name == "Test App"
|
||||||
|
assert layout._state is not None
|
||||||
|
|
||||||
|
def test_i_can_set_main_content(self, root_instance):
|
||||||
|
"""Test setting main content area."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
content = Div("Main content")
|
||||||
|
|
||||||
|
result = layout.set_main(content)
|
||||||
|
|
||||||
|
assert layout._main_content == content
|
||||||
|
assert result == layout # Should return self for chaining
|
||||||
|
|
||||||
|
def test_i_can_add_content_to_left_drawer(self, root_instance):
|
||||||
|
"""Test adding content to left drawer."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
content = Div("Left drawer content", id="drawer_item")
|
||||||
|
|
||||||
|
layout.left_drawer.add(content)
|
||||||
|
|
||||||
|
drawer_content = layout.left_drawer.get_content()
|
||||||
|
assert None in drawer_content
|
||||||
|
assert content in drawer_content[None]
|
||||||
|
|
||||||
|
def test_i_can_add_content_to_right_drawer(self, root_instance):
|
||||||
|
"""Test adding content to right drawer."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
content = Div("Right drawer content", id="drawer_item")
|
||||||
|
|
||||||
|
layout.right_drawer.add(content)
|
||||||
|
|
||||||
|
drawer_content = layout.right_drawer.get_content()
|
||||||
|
assert None in drawer_content
|
||||||
|
assert content in drawer_content[None]
|
||||||
|
|
||||||
|
def test_i_can_add_content_to_header_left(self, root_instance):
|
||||||
|
"""Test adding content to left side of header."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
content = Div("Header left content", id="header_item")
|
||||||
|
|
||||||
|
layout.header_left.add(content)
|
||||||
|
|
||||||
|
header_content = layout.header_left.get_content()
|
||||||
|
assert None in header_content
|
||||||
|
assert content in header_content[None]
|
||||||
|
|
||||||
|
def test_i_can_add_content_to_header_right(self, root_instance):
|
||||||
|
"""Test adding content to right side of header."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
content = Div("Header right content", id="header_item")
|
||||||
|
|
||||||
|
layout.header_right.add(content)
|
||||||
|
|
||||||
|
header_content = layout.header_right.get_content()
|
||||||
|
assert None in header_content
|
||||||
|
assert content in header_content[None]
|
||||||
|
|
||||||
|
def test_i_can_add_grouped_content(self, root_instance):
|
||||||
|
"""Test adding content with custom groups."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
group_name = "navigation"
|
||||||
|
content1 = Div("Nav item 1", id="nav1")
|
||||||
|
content2 = Div("Nav item 2", id="nav2")
|
||||||
|
|
||||||
|
layout.left_drawer.add(content1, group=group_name)
|
||||||
|
layout.left_drawer.add(content2, group=group_name)
|
||||||
|
|
||||||
|
drawer_content = layout.left_drawer.get_content()
|
||||||
|
assert group_name in drawer_content
|
||||||
|
assert content1 in drawer_content[group_name]
|
||||||
|
assert content2 in drawer_content[group_name]
|
||||||
|
|
||||||
|
def test_i_cannot_add_duplicate_content(self, root_instance):
|
||||||
|
"""Test that content with same ID is not added twice."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
content = Div("Content", id="unique_id")
|
||||||
|
|
||||||
|
layout.left_drawer.add(content)
|
||||||
|
layout.left_drawer.add(content) # Try to add again
|
||||||
|
|
||||||
|
drawer_content = layout.left_drawer.get_content()
|
||||||
|
# Content should appear only once
|
||||||
|
assert drawer_content[None].count(content) == 1
|
||||||
|
|
||||||
|
def test_i_can_toggle_left_drawer(self, root_instance):
|
||||||
|
"""Test toggling left drawer open/closed."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
|
||||||
|
# Initially open
|
||||||
|
assert layout._state.left_drawer_open is True
|
||||||
|
|
||||||
|
# Toggle to close
|
||||||
|
layout.toggle_drawer("left")
|
||||||
|
assert layout._state.left_drawer_open is False
|
||||||
|
|
||||||
|
# Toggle to open
|
||||||
|
layout.toggle_drawer("left")
|
||||||
|
assert layout._state.left_drawer_open is True
|
||||||
|
|
||||||
|
def test_i_can_toggle_right_drawer(self, root_instance):
|
||||||
|
"""Test toggling right drawer open/closed."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
|
||||||
|
# Initially open
|
||||||
|
assert layout._state.right_drawer_open is True
|
||||||
|
|
||||||
|
# Toggle to close
|
||||||
|
layout.toggle_drawer("right")
|
||||||
|
assert layout._state.right_drawer_open is False
|
||||||
|
|
||||||
|
# Toggle to open
|
||||||
|
layout.toggle_drawer("right")
|
||||||
|
assert layout._state.right_drawer_open is True
|
||||||
|
|
||||||
|
def test_i_can_update_left_drawer_width(self, root_instance):
|
||||||
|
"""Test updating left drawer width."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
new_width = 300
|
||||||
|
|
||||||
|
layout.update_drawer_width("left", new_width)
|
||||||
|
|
||||||
|
assert layout._state.left_drawer_width == new_width
|
||||||
|
|
||||||
|
def test_i_can_update_right_drawer_width(self, root_instance):
|
||||||
|
"""Test updating right drawer width."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
new_width = 400
|
||||||
|
|
||||||
|
layout.update_drawer_width("right", new_width)
|
||||||
|
|
||||||
|
assert layout._state.right_drawer_width == new_width
|
||||||
|
|
||||||
|
def test_i_cannot_set_drawer_width_below_minimum(self, root_instance):
|
||||||
|
"""Test that drawer width is constrained to minimum 150px."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
|
||||||
|
layout.update_drawer_width("left", 100) # Try to set below minimum
|
||||||
|
|
||||||
|
assert layout._state.left_drawer_width == 150 # Should be clamped to min
|
||||||
|
|
||||||
|
def test_i_cannot_set_drawer_width_above_maximum(self, root_instance):
|
||||||
|
"""Test that drawer width is constrained to maximum 600px."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
|
||||||
|
layout.update_drawer_width("right", 800) # Try to set above maximum
|
||||||
|
|
||||||
|
assert layout._state.right_drawer_width == 600 # Should be clamped to max
|
||||||
|
|
||||||
|
def test_i_cannot_toggle_invalid_drawer_side(self, root_instance):
|
||||||
|
"""Test that toggling invalid drawer side raises ValueError."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid drawer side"):
|
||||||
|
layout.toggle_drawer("invalid")
|
||||||
|
|
||||||
|
def test_i_cannot_update_invalid_drawer_width(self, root_instance):
|
||||||
|
"""Test that updating invalid drawer side raises ValueError."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Invalid drawer side"):
|
||||||
|
layout.update_drawer_width("invalid", 250)
|
||||||
|
|
||||||
|
def test_layout_state_has_correct_defaults(self, root_instance):
|
||||||
|
"""Test that LayoutState initializes with correct default values."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
state = layout._state
|
||||||
|
|
||||||
|
assert state.left_drawer_open is True
|
||||||
|
assert state.right_drawer_open is True
|
||||||
|
assert state.left_drawer_width == 250
|
||||||
|
assert state.right_drawer_width == 250
|
||||||
|
|
||||||
|
def test_layout_is_single_instance(self, root_instance):
|
||||||
|
"""Test that Layout behaves as SingleInstance (same ID returns same instance)."""
|
||||||
|
layout1 = Layout(root_instance, app_name="Test App", _id="my_layout")
|
||||||
|
layout2 = Layout(root_instance, app_name="Test App", _id="my_layout")
|
||||||
|
|
||||||
|
# Should be the same instance
|
||||||
|
assert layout1 is layout2
|
||||||
|
|
||||||
|
def test_commands_are_created(self, root_instance):
|
||||||
|
"""Test that Layout creates necessary commands."""
|
||||||
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
|
|
||||||
|
# Test toggle commands
|
||||||
|
left_toggle_cmd = layout.commands.toggle_drawer("left")
|
||||||
|
assert left_toggle_cmd is not None
|
||||||
|
assert left_toggle_cmd.id is not None
|
||||||
|
|
||||||
|
right_toggle_cmd = layout.commands.toggle_drawer("right")
|
||||||
|
assert right_toggle_cmd is not None
|
||||||
|
assert right_toggle_cmd.id is not None
|
||||||
|
|
||||||
|
# Test width update commands
|
||||||
|
left_width_cmd = layout.commands.update_drawer_width("left")
|
||||||
|
assert left_width_cmd is not None
|
||||||
|
assert left_width_cmd.id is not None
|
||||||
|
|
||||||
|
right_width_cmd = layout.commands.update_drawer_width("right")
|
||||||
|
assert right_width_cmd is not None
|
||||||
|
assert right_width_cmd.id is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayoutRender:
|
||||||
|
"""Tests for Layout HTML rendering."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def layout(self, root_instance):
|
||||||
|
return Layout(root_instance, app_name="Test App")
|
||||||
|
|
||||||
|
def test_empty_layout_is_rendered(self, layout):
|
||||||
|
"""Test that Layout renders with all main structural sections.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- 7 children: Verifies all main sections are rendered (tooltip container, header, drawers, main, footer, script)
|
||||||
|
- _id: Essential for layout identification and resizer initialization
|
||||||
|
- cls="mf-layout": Root CSS class for layout styling
|
||||||
|
"""
|
||||||
|
expected = Div(
|
||||||
|
Div(), # tooltip container
|
||||||
|
Header(),
|
||||||
|
Div(), # left drawer
|
||||||
|
Main(),
|
||||||
|
Div(), # right drawer
|
||||||
|
Footer(),
|
||||||
|
Script(),
|
||||||
|
_id=layout._id,
|
||||||
|
cls="mf-layout"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(layout.render(), expected)
|
||||||
|
|
||||||
|
def test_header_has_two_sides(self, layout):
|
||||||
|
"""Test that there is a left and right header section."""
|
||||||
|
header = find_one(layout.render(), Header(cls="mf-layout-header"))
|
||||||
|
|
||||||
|
expected = Header(
|
||||||
|
Div(id=f"{layout._id}_hl"),
|
||||||
|
Div(id=f"{layout._id}_hr"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(header, expected)
|
||||||
|
|
||||||
|
def test_footer_has_two_sides(self, layout):
|
||||||
|
"""Test that there is a left and right footer section."""
|
||||||
|
footer = find_one(layout.render(), Footer(cls=Contains("mf-layout-footer")))
|
||||||
|
|
||||||
|
expected = Footer(
|
||||||
|
Div(id=f"{layout._id}_fl"),
|
||||||
|
Div(id=f"{layout._id}_fr"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(footer, expected)
|
||||||
|
|
||||||
|
def test_header_with_drawer_icons_is_rendered(self, layout):
|
||||||
|
"""Test that header renders with drawer toggle icons.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Only the first div is required to test the presence of the icon
|
||||||
|
- Use TestIcon to test the existence of an icon
|
||||||
|
"""
|
||||||
|
header = find_one(layout.render(), Header(cls="mf-layout-header"))
|
||||||
|
|
||||||
|
expected = Header(
|
||||||
|
Div(
|
||||||
|
TestIcon("PanelLeftContract20Regular"),
|
||||||
|
cls="flex gap-1"
|
||||||
|
),
|
||||||
|
cls="mf-layout-header"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(header, expected)
|
||||||
|
|
||||||
|
def test_main_content_is_rendered_with_some_element(self, layout):
|
||||||
|
"""Test that main content area renders correctly.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- cls="mf-layout-main": Root main class for styling
|
||||||
|
"""
|
||||||
|
layout.set_main(Div("Main content"))
|
||||||
|
main = find_one(layout.render(), Main(cls="mf-layout-main"))
|
||||||
|
|
||||||
|
expected = Main(
|
||||||
|
Div("Main content"),
|
||||||
|
cls="mf-layout-main"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(main, expected)
|
||||||
|
|
||||||
|
def test_left_drawer_is_rendered_when_open(self, layout):
|
||||||
|
"""Test that left drawer renders with correct classes when open.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- _id: Required for targeting drawer in HTMX updates. search by id whenever possible
|
||||||
|
- cls Contains "mf-layout-drawer": Base drawer class for styling
|
||||||
|
- cls Contains "mf-layout-left-drawer": Left-specific drawer positioning
|
||||||
|
- style Contains width: Drawer width must be applied for sizing
|
||||||
|
"""
|
||||||
|
layout._state.left_drawer_open = True
|
||||||
|
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
_id=f"{layout._id}_ld",
|
||||||
|
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"),
|
||||||
|
style=Contains("width: 250px")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(drawer, expected)
|
||||||
|
|
||||||
|
def test_left_drawer_has_collapsed_class_when_closed(self, layout):
|
||||||
|
"""Test that left drawer renders with collapsed class when closed.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- _id: Required for targeting drawer in HTMX updates
|
||||||
|
- cls Contains "collapsed": Class triggers CSS hiding animation
|
||||||
|
- style Contains "width: 0px": Zero width is crucial for collapse animation
|
||||||
|
"""
|
||||||
|
layout._state.left_drawer_open = False
|
||||||
|
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
_id=f"{layout._id}_ld",
|
||||||
|
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer", "collapsed"),
|
||||||
|
style=Contains("width: 0px")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(drawer, expected)
|
||||||
|
|
||||||
|
def test_right_drawer_is_rendered_when_open(self, layout):
|
||||||
|
"""Test that right drawer renders with correct classes when open.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- _id: Required for targeting drawer in HTMX updates
|
||||||
|
- cls Contains "mf-layout-drawer": Base drawer class for styling
|
||||||
|
- cls Contains "mf-layout-right-drawer": Right-specific drawer positioning
|
||||||
|
- style Contains width: Drawer width must be applied for sizing
|
||||||
|
"""
|
||||||
|
layout._state.right_drawer_open = True
|
||||||
|
drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
_id=f"{layout._id}_rd",
|
||||||
|
cls=Contains("mf-layout-drawer", "mf-layout-right-drawer"),
|
||||||
|
style=Contains("width: 250px")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(drawer, expected)
|
||||||
|
|
||||||
|
def test_right_drawer_has_collapsed_class_when_closed(self, layout):
|
||||||
|
"""Test that right drawer renders with collapsed class when closed.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- _id: Required for targeting drawer in HTMX updates
|
||||||
|
- cls Contains "collapsed": Class triggers CSS hiding animation
|
||||||
|
- style Contains "width: 0px": Zero width is crucial for collapse animation
|
||||||
|
"""
|
||||||
|
layout._state.right_drawer_open = False
|
||||||
|
drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
_id=f"{layout._id}_rd",
|
||||||
|
cls=Contains("mf-layout-drawer", "mf-layout-right-drawer", "collapsed"),
|
||||||
|
style=Contains("width: 0px")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(drawer, expected)
|
||||||
|
|
||||||
|
def test_drawer_width_is_applied_as_style(self, layout):
|
||||||
|
"""Test that custom drawer width is applied as inline style.
|
||||||
|
|
||||||
|
Why this test matters:
|
||||||
|
- style Contains "width: 300px": Verifies that width updates are reflected in style attribute
|
||||||
|
"""
|
||||||
|
layout._state.left_drawer_width = 300
|
||||||
|
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
style=Contains("width: 300px")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(drawer, expected)
|
||||||
|
|
||||||
|
def test_left_drawer_has_resizer_element(self, layout):
|
||||||
|
"""Test that left drawer contains resizer element.
|
||||||
|
|
||||||
|
Why this test matters:
|
||||||
|
- Resizer element must be present for drawer width adjustment
|
||||||
|
- cls "mf-resizer-left": Left-specific resizer for correct edge positioning
|
||||||
|
"""
|
||||||
|
drawer = find(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||||
|
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||||
|
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
|
||||||
|
|
||||||
|
def test_right_drawer_has_resizer_element(self, layout):
|
||||||
|
"""Test that right drawer contains resizer element.
|
||||||
|
|
||||||
|
Why this test matters:
|
||||||
|
- Resizer element must be present for drawer width adjustment
|
||||||
|
- cls "mf-resizer-right": Right-specific resizer for correct edge positioning
|
||||||
|
"""
|
||||||
|
drawer = find(layout.render(), Div(id=f"{layout._id}_rd"))
|
||||||
|
resizers = find(drawer, Div(cls=Contains("mf-resizer-right")))
|
||||||
|
assert len(resizers) == 1, "Right drawer should contain exactly one resizer element"
|
||||||
|
|
||||||
|
def test_resizer_script_is_included(self, layout):
|
||||||
|
"""Test that resizer initialization script is included in render.
|
||||||
|
|
||||||
|
Why this test matters:
|
||||||
|
- Script element: Required to initialize resizer functionality
|
||||||
|
- Script contains initLayout call: Ensures layout is activated for this layout instance
|
||||||
|
"""
|
||||||
|
script = find_one(layout.render(), Script())
|
||||||
|
expected = TestScript(f"initLayout('{layout._id}');")
|
||||||
|
|
||||||
|
assert matches(script, expected)
|
||||||
|
|
||||||
|
def test_left_drawer_renders_content_with_groups(self, layout):
|
||||||
|
"""Test that left drawer renders content organized by groups with proper wrappers.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- mf-layout-drawer-content wrapper: Required container for drawer scrolling behavior
|
||||||
|
- divider elements: Visual separation between content groups
|
||||||
|
- Group count validation: Ensures all added groups are rendered
|
||||||
|
"""
|
||||||
|
layout.left_drawer.add(Div("Item 1", id="item1"), group="group1")
|
||||||
|
layout.left_drawer.add(Div("Item 2", id="item2"), group="group2")
|
||||||
|
|
||||||
|
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||||
|
|
||||||
|
content_wrappers = find(drawer, Div(cls="mf-layout-drawer-content"))
|
||||||
|
assert len(content_wrappers) == 1, "Left drawer should contain exactly one content wrapper"
|
||||||
|
|
||||||
|
content = content_wrappers[0]
|
||||||
|
dividers = find(content, Div(cls="divider"))
|
||||||
|
assert len(dividers) == 1, "Two groups should be separated by exactly one divider"
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_left_renders_custom_content(self, layout):
|
||||||
|
"""Test that custom content added to header_left is rendered in the left header section.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- id="{layout._id}_hl": Essential for HTMX targeting during updates
|
||||||
|
- cls Contains "flex": Ensures horizontal layout of header items
|
||||||
|
- Icon presence: Toggle drawer icon must always be first element
|
||||||
|
- Custom content: Verifies header_left.add() correctly renders content
|
||||||
|
"""
|
||||||
|
custom_content = Div("Custom Header", id="custom_header")
|
||||||
|
layout.header_left.add(custom_content)
|
||||||
|
|
||||||
|
header_left = find_one(layout.render(), Div(id=f"{layout._id}_hl"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
TestIcon(""),
|
||||||
|
Skip(None),
|
||||||
|
Div("Custom Header", id="custom_header"),
|
||||||
|
id=f"{layout._id}_hl",
|
||||||
|
cls=Contains("flex", "gap-1")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(header_left, expected)
|
||||||
|
|
||||||
|
def test_header_right_renders_custom_content(self, layout):
|
||||||
|
"""Test that custom content added to header_right is rendered in the right header section.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- id="{layout._id}_hr": Essential for HTMX targeting during updates
|
||||||
|
- cls Contains "flex": Ensures horizontal layout of header items
|
||||||
|
- Custom content: Verifies header_right.add() correctly renders content
|
||||||
|
- UserProfile component: Must always be last element in right header
|
||||||
|
"""
|
||||||
|
custom_content = Div("Custom Header Right", id="custom_header_right")
|
||||||
|
layout.header_right.add(custom_content)
|
||||||
|
|
||||||
|
header_right = find_one(layout.render(), Div(id=f"{layout._id}_hr"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
Skip(None),
|
||||||
|
Div("Custom Header Right", id="custom_header_right"),
|
||||||
|
TestObject(UserProfile),
|
||||||
|
id=f"{layout._id}_hr",
|
||||||
|
cls=Contains("flex", "gap-1")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(header_right, expected)
|
||||||
|
|
||||||
|
def test_footer_left_renders_custom_content(self, layout):
|
||||||
|
"""Test that custom content added to footer_left is rendered in the left footer section.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- id="{layout._id}_fl": Essential for HTMX targeting during updates
|
||||||
|
- cls Contains "flex": Ensures horizontal layout of footer items
|
||||||
|
- Custom content: Verifies footer_left.add() correctly renders content
|
||||||
|
"""
|
||||||
|
custom_content = Div("Custom Footer Left", id="custom_footer_left")
|
||||||
|
layout.footer_left.add(custom_content)
|
||||||
|
|
||||||
|
footer_left = find_one(layout.render(), Div(id=f"{layout._id}_fl"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
Skip(None),
|
||||||
|
Div("Custom Footer Left", id="custom_footer_left"),
|
||||||
|
id=f"{layout._id}_fl",
|
||||||
|
cls=Contains("flex", "gap-1")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(footer_left, expected)
|
||||||
|
|
||||||
|
def test_footer_right_renders_custom_content(self, layout):
|
||||||
|
"""Test that custom content added to footer_right is rendered in the right footer section.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- id="{layout._id}_fr": Essential for HTMX targeting during updates
|
||||||
|
- cls Contains "flex": Ensures horizontal layout of footer items
|
||||||
|
- Custom content: Verifies footer_right.add() correctly renders content
|
||||||
|
"""
|
||||||
|
custom_content = Div("Custom Footer Right", id="custom_footer_right")
|
||||||
|
layout.footer_right.add(custom_content)
|
||||||
|
|
||||||
|
footer_right = find_one(layout.render(), Div(id=f"{layout._id}_fr"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
Skip(None),
|
||||||
|
Div("Custom Footer Right", id="custom_footer_right"),
|
||||||
|
id=f"{layout._id}_fr",
|
||||||
|
cls=Contains("flex", "gap-1")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(footer_right, expected)
|
||||||
|
|
||||||
|
def test_left_drawer_resizer_has_command_data(self, layout):
|
||||||
|
"""Test that left drawer resizer has correct data attributes for command binding.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- data_command_id: JavaScript uses this to trigger width update command
|
||||||
|
- data_side="left": JavaScript needs this to identify which drawer to resize
|
||||||
|
- cls Contains "mf-resizer-left": CSS uses this for left-specific positioning
|
||||||
|
"""
|
||||||
|
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||||
|
|
||||||
|
resizer = find_one(drawer, Div(cls=Contains("mf-resizer-left")))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
cls=Contains("mf-resizer", "mf-resizer-left"),
|
||||||
|
data_command_id=AnyValue(),
|
||||||
|
data_side="left"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(resizer, expected)
|
||||||
|
|
||||||
|
def test_right_drawer_resizer_has_command_data(self, layout):
|
||||||
|
"""Test that right drawer resizer has correct data attributes for command binding.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- data_command_id: JavaScript uses this to trigger width update command
|
||||||
|
- data_side="right": JavaScript needs this to identify which drawer to resize
|
||||||
|
- cls Contains "mf-resizer-right": CSS uses this for right-specific positioning
|
||||||
|
"""
|
||||||
|
drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
|
||||||
|
|
||||||
|
resizer = find_one(drawer, Div(cls=Contains("mf-resizer-right")))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
cls=Contains("mf-resizer", "mf-resizer-right"),
|
||||||
|
data_command_id=AnyValue(),
|
||||||
|
data_side="right"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(resizer, expected)
|
||||||
|
|
||||||
|
def test_left_drawer_icon_changes_when_closed(self, layout):
|
||||||
|
"""Test that left drawer toggle icon changes from expand to collapse when drawer is closed.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- id="{layout._id}_ldi": Required for HTMX swap-oob updates when toggling
|
||||||
|
- Icon type: Visual feedback to user about drawer state (expand icon when closed)
|
||||||
|
- Icon change: Validates that toggle_drawer returns correct icon
|
||||||
|
"""
|
||||||
|
layout._state.left_drawer_open = False
|
||||||
|
|
||||||
|
icon_div = find_one(layout.render(), Div(id=f"{layout._id}_ldi"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
TestIconNotStr("panel_left_expand20_regular"),
|
||||||
|
id=f"{layout._id}_ldi"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(icon_div, expected)
|
||||||
|
|
||||||
|
def test_left_drawer_icon_changes_when_opne(self, layout):
|
||||||
|
"""Test that left drawer toggle icon changes from collapse to expand when drawer is open..
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- id="{layout._id}_ldi": Required for HTMX swap-oob updates when toggling
|
||||||
|
- Icon type: Visual feedback to user about drawer state (expand icon when closed)
|
||||||
|
- Icon change: Validates that toggle_drawer returns correct icon
|
||||||
|
"""
|
||||||
|
layout._state.left_drawer_open = True
|
||||||
|
|
||||||
|
icon_div = find_one(layout.render(), Div(id=f"{layout._id}_ldi"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
TestIconNotStr("panel_left_contract20_regular"),
|
||||||
|
id=f"{layout._id}_ldi"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(icon_div, expected)
|
||||||
|
|
||||||
|
def test_tooltip_container_is_rendered(self, layout):
|
||||||
|
"""Test that tooltip container is rendered at the top of the layout.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- id="tt_{layout._id}": JavaScript uses this to append dynamically created tooltips
|
||||||
|
- cls Contains "mf-tooltip-container": CSS positioning for tooltip overlay layer
|
||||||
|
- Presence verification: Tooltips won't work if container is missing
|
||||||
|
"""
|
||||||
|
tooltip_container = find_one(layout.render(), Div(id=f"tt_{layout._id}"))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
id=f"tt_{layout._id}",
|
||||||
|
cls=Contains("mf-tooltip-container")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(tooltip_container, expected)
|
||||||
|
|
||||||
|
def test_header_right_contains_user_profile(self, layout):
|
||||||
|
"""Test that UserProfile component is rendered in the right header section.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- UserProfile component: Provides authentication and user menu functionality
|
||||||
|
- Position in header right: Conventional placement for user profile controls
|
||||||
|
- Count verification: Ensures component is not duplicated
|
||||||
|
"""
|
||||||
|
header_right = find_one(layout.render(), Div(id=f"{layout._id}_hr"))
|
||||||
|
|
||||||
|
user_profiles = find(header_right, TestObject(UserProfile))
|
||||||
|
|
||||||
|
assert len(user_profiles) == 1, "Header right should contain exactly one UserProfile component"
|
||||||
|
|
||||||
|
def test_layout_initialization_script_is_included(self, layout):
|
||||||
|
"""Test that layout initialization script is included in render output.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Script presence: Required to initialize layout behavior (resizers, drawers)
|
||||||
|
- initLayout() call: Activates JavaScript functionality for this layout instance
|
||||||
|
- Layout ID parameter: Ensures initialization targets correct layout
|
||||||
|
"""
|
||||||
|
script = find_one(layout.render(), Script())
|
||||||
|
|
||||||
|
expected = TestScript(f"initLayout('{layout._id}');")
|
||||||
|
|
||||||
|
assert matches(script, expected)
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import shutil
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
from fasthtml.xtend import Script
|
from fasthtml.xtend import Script
|
||||||
@@ -10,9 +12,11 @@ from .conftest import session
|
|||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def tabs_manager(root_instance):
|
def tabs_manager(root_instance):
|
||||||
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||||
yield TabsManager(root_instance)
|
yield TabsManager(root_instance)
|
||||||
|
|
||||||
InstancesManager.reset()
|
InstancesManager.reset()
|
||||||
|
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
class TestTabsManagerBehaviour:
|
class TestTabsManagerBehaviour:
|
||||||
|
|||||||
860
tests/controls/test_treeview.py
Normal file
860
tests/controls/test_treeview.py
Normal file
@@ -0,0 +1,860 @@
|
|||||||
|
"""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, TestIcon, find_one, find, Contains, \
|
||||||
|
DoesNotContain
|
||||||
|
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")
|
||||||
|
|
||||||
|
def test_i_can_initialize_with_items_dict(self, root_instance):
|
||||||
|
"""Test that TreeView can be initialized with a dictionary of items."""
|
||||||
|
node1 = TreeNode(label="Node 1", type="folder")
|
||||||
|
node2 = TreeNode(label="Node 2", type="file")
|
||||||
|
|
||||||
|
items = {node1.id: node1, node2.id: node2}
|
||||||
|
tree_view = TreeView(root_instance, items=items)
|
||||||
|
|
||||||
|
assert len(tree_view._state.items) == 2
|
||||||
|
assert tree_view._state.items[node1.id].label == "Node 1"
|
||||||
|
assert tree_view._state.items[node1.id].type == "folder"
|
||||||
|
assert tree_view._state.items[node2.id].label == "Node 2"
|
||||||
|
assert tree_view._state.items[node2.id].type == "file"
|
||||||
|
|
||||||
|
|
||||||
|
class TestTreeViewRender:
|
||||||
|
"""Tests for TreeView HTML rendering."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tree_view(self, root_instance):
|
||||||
|
return TreeView(root_instance)
|
||||||
|
|
||||||
|
def test_empty_treeview_is_rendered(self, tree_view):
|
||||||
|
"""Test that empty TreeView generates correct HTML structure.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- TestObject Keyboard: Essential for keyboard shortcuts (Escape to cancel rename)
|
||||||
|
- _id: Required for HTMX targeting and component identification
|
||||||
|
- cls "mf-treeview": Root CSS class for TreeView styling
|
||||||
|
"""
|
||||||
|
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_with_children_collapsed_is_rendered(self, tree_view):
|
||||||
|
"""Test that a collapsed node with children renders correctly.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- TestIcon chevron_right: Indicates visually that the node is collapsed
|
||||||
|
- Span with label: Displays the node's text content
|
||||||
|
- Action buttons (add_child, edit, delete): Enable user interactions
|
||||||
|
- cls "mf-treenode": Required CSS class for node styling
|
||||||
|
- data_node_id: Essential for identifying the node in DOM operations
|
||||||
|
- No children in container: Verifies children are hidden when collapsed
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Step 1: Extract the node element to test
|
||||||
|
rendered = tree_view.render()
|
||||||
|
|
||||||
|
# Step 2: Define expected structure
|
||||||
|
expected = Div(
|
||||||
|
Div(
|
||||||
|
Div(
|
||||||
|
TestIcon("chevron_right20_regular"), # Collapsed toggle icon
|
||||||
|
Span("Parent"), # Label
|
||||||
|
Div( # Action buttons
|
||||||
|
TestIcon("add_circle20_regular"),
|
||||||
|
TestIcon("edit20_regular"),
|
||||||
|
TestIcon("delete20_regular"),
|
||||||
|
cls=Contains("mf-treenode-actions")
|
||||||
|
),
|
||||||
|
cls=Contains("mf-treenode"),
|
||||||
|
),
|
||||||
|
cls="mf-treenode-container",
|
||||||
|
data_node_id=parent.id
|
||||||
|
),
|
||||||
|
id=tree_view.get_id()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Compare
|
||||||
|
assert matches(rendered, expected)
|
||||||
|
|
||||||
|
# Verify no children are rendered (collapsed)
|
||||||
|
child_containers = find(rendered, Div(data_node_id=parent.id))
|
||||||
|
assert len(child_containers) == 1, "Children should not be rendered when node is collapsed"
|
||||||
|
|
||||||
|
def test_node_with_children_expanded_is_rendered(self, tree_view):
|
||||||
|
"""Test that an expanded node with children renders correctly.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- TestIcon chevron_down: Indicates visually that the node is expanded
|
||||||
|
- Children rendered: Verifies that child nodes are visible when parent is expanded
|
||||||
|
- Child has its own node structure: Ensures recursive rendering works correctly
|
||||||
|
|
||||||
|
Rendered Structure :
|
||||||
|
Div (node_container with data_node_id)
|
||||||
|
├─ Div (information on current node - icon, label, actions)
|
||||||
|
└─ Div* (children - recursive containers, only if expanded)
|
||||||
|
"""
|
||||||
|
parent = TreeNode(label="Parent", type="folder")
|
||||||
|
child1 = TreeNode(label="Child1", type="file")
|
||||||
|
child2 = TreeNode(label="Child2", type="file")
|
||||||
|
|
||||||
|
tree_view.add_node(parent)
|
||||||
|
tree_view.add_node(child1, parent_id=parent.id)
|
||||||
|
tree_view.add_node(child2, parent_id=parent.id)
|
||||||
|
tree_view._toggle_node(parent.id) # Expand the parent
|
||||||
|
|
||||||
|
# Step 1: Extract the parent node element to test
|
||||||
|
rendered = tree_view.render()
|
||||||
|
parent_container = find_one(rendered, Div(data_node_id=parent.id))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
Div(), # parent info (see test_node_with_children_collapsed_is_rendered)
|
||||||
|
Div(data_node_id=child1.id),
|
||||||
|
Div(data_node_id=child2.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Compare
|
||||||
|
assert matches(parent_container, expected)
|
||||||
|
|
||||||
|
# now check the child node structure
|
||||||
|
child_container = find_one(rendered, Div(data_node_id=child1.id))
|
||||||
|
expected_child_container = Div(
|
||||||
|
Div(
|
||||||
|
Div(None), # No icon, the div is empty
|
||||||
|
Span("Child1"),
|
||||||
|
Div(), # action buttons
|
||||||
|
cls=Contains("mf-treenode")
|
||||||
|
),
|
||||||
|
cls="mf-treenode-container",
|
||||||
|
data_node_id=child1.id,
|
||||||
|
)
|
||||||
|
assert matches(child_container, expected_child_container)
|
||||||
|
|
||||||
|
def test_leaf_node_is_rendered(self, tree_view):
|
||||||
|
"""Test that a leaf node (no children) renders without toggle icon.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- No toggle icon (or empty space): Leaf nodes don't need expand/collapse functionality
|
||||||
|
- Span with label: Displays the node's text content
|
||||||
|
- Action buttons present: Even leaf nodes can be edited, deleted, or receive children
|
||||||
|
"""
|
||||||
|
leaf = TreeNode(label="Leaf Node", type="file")
|
||||||
|
tree_view.add_node(leaf)
|
||||||
|
|
||||||
|
# Step 1: Extract the leaf node element to test
|
||||||
|
rendered = tree_view.render()
|
||||||
|
leaf_container = find_one(rendered, Div(data_node_id=leaf.id))
|
||||||
|
|
||||||
|
# Step 2: Define expected structure
|
||||||
|
expected = Div(
|
||||||
|
Div(
|
||||||
|
Div(None), # No icon, the div is empty
|
||||||
|
Span("Leaf Node"), # Label
|
||||||
|
Div(), # Action buttons still present
|
||||||
|
),
|
||||||
|
cls=Contains("mf-treenode"),
|
||||||
|
data_node_id=leaf.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Compare
|
||||||
|
assert matches(leaf_container, expected)
|
||||||
|
|
||||||
|
def test_selected_node_has_selected_class(self, tree_view):
|
||||||
|
"""Test that a selected node has the 'selected' CSS class.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- cls Contains "selected": Enables visual highlighting of the selected node
|
||||||
|
- Div with mf-treenode: The node information container with selected class
|
||||||
|
- data_node_id: Required for identifying which node is selected
|
||||||
|
"""
|
||||||
|
node = TreeNode(label="Selected Node", type="file")
|
||||||
|
tree_view.add_node(node)
|
||||||
|
tree_view._select_node(node.id)
|
||||||
|
|
||||||
|
rendered = tree_view.render()
|
||||||
|
selected_container = find_one(rendered, Div(data_node_id=node.id))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
Div(
|
||||||
|
Div(None), # No icon, leaf node
|
||||||
|
Span("Selected Node"),
|
||||||
|
Div(), # Action buttons
|
||||||
|
cls=Contains("mf-treenode", "selected")
|
||||||
|
),
|
||||||
|
cls="mf-treenode-container",
|
||||||
|
data_node_id=node.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(selected_container, expected)
|
||||||
|
|
||||||
|
def test_node_in_editing_mode_shows_input(self, tree_view):
|
||||||
|
"""Test that a node in editing mode renders an Input instead of Span.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Input element: Enables user to modify the node label inline
|
||||||
|
- cls "mf-treenode-input": Required CSS class for input field styling
|
||||||
|
- name "node_label": Essential for form data submission
|
||||||
|
- value with current label: Pre-fills the input with existing text
|
||||||
|
- cls does NOT contain "selected": Avoids double highlighting during editing
|
||||||
|
"""
|
||||||
|
node = TreeNode(label="Edit Me", type="file")
|
||||||
|
tree_view.add_node(node)
|
||||||
|
tree_view._start_rename(node.id)
|
||||||
|
|
||||||
|
rendered = tree_view.render()
|
||||||
|
editing_container = find_one(rendered, Div(data_node_id=node.id))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
Div(
|
||||||
|
Div(None), # No icon, leaf node
|
||||||
|
Input(
|
||||||
|
name="node_label",
|
||||||
|
value="Edit Me",
|
||||||
|
cls=Contains("mf-treenode-input")
|
||||||
|
),
|
||||||
|
Div(), # Action buttons
|
||||||
|
cls=Contains("mf-treenode")
|
||||||
|
),
|
||||||
|
cls="mf-treenode-container",
|
||||||
|
data_node_id=node.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(editing_container, expected)
|
||||||
|
|
||||||
|
# Verify "selected" class is NOT present
|
||||||
|
editing_node_info = find_one(editing_container, Div(cls=Contains("mf-treenode", _word=True)))
|
||||||
|
no_selected = Div(
|
||||||
|
cls=DoesNotContain("selected")
|
||||||
|
)
|
||||||
|
assert matches(editing_node_info, no_selected)
|
||||||
|
|
||||||
|
def test_node_indentation_increases_with_level(self, tree_view):
|
||||||
|
"""Test that node indentation increases correctly with hierarchy level.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- style Contains "padding-left: 0px": Root node has no indentation
|
||||||
|
- style Contains "padding-left: 20px": Child is indented by 20px
|
||||||
|
- style Contains "padding-left: 40px": Grandchild is indented by 40px
|
||||||
|
- Progressive padding: Creates the visual hierarchy of the tree structure
|
||||||
|
- Padding is applied to the node info Div, not the container
|
||||||
|
"""
|
||||||
|
root = TreeNode(label="Root", type="folder")
|
||||||
|
child = TreeNode(label="Child", type="folder")
|
||||||
|
grandchild = TreeNode(label="Grandchild", type="file")
|
||||||
|
|
||||||
|
tree_view.add_node(root)
|
||||||
|
tree_view.add_node(child, parent_id=root.id)
|
||||||
|
tree_view.add_node(grandchild, parent_id=child.id)
|
||||||
|
|
||||||
|
# Expand all to make hierarchy visible
|
||||||
|
tree_view._toggle_node(root.id)
|
||||||
|
tree_view._toggle_node(child.id)
|
||||||
|
|
||||||
|
rendered = tree_view.render()
|
||||||
|
|
||||||
|
# Test root node (level 0)
|
||||||
|
root_container = find_one(rendered, Div(data_node_id=root.id))
|
||||||
|
root_expected = Div(
|
||||||
|
Div(
|
||||||
|
TestIcon("chevron_down20_regular"), # Expanded icon
|
||||||
|
Span("Root"),
|
||||||
|
Div(), # Action buttons
|
||||||
|
cls=Contains("mf-treenode"),
|
||||||
|
style=Contains("padding-left: 0px")
|
||||||
|
),
|
||||||
|
cls="mf-treenode-container",
|
||||||
|
data_node_id=root.id
|
||||||
|
)
|
||||||
|
assert matches(root_container, root_expected)
|
||||||
|
|
||||||
|
# Test child node (level 1)
|
||||||
|
child_container = find_one(rendered, Div(data_node_id=child.id))
|
||||||
|
child_expected = Div(
|
||||||
|
Div(
|
||||||
|
TestIcon("chevron_down20_regular"), # Expanded icon
|
||||||
|
Span("Child"),
|
||||||
|
Div(), # Action buttons
|
||||||
|
cls=Contains("mf-treenode"),
|
||||||
|
style=Contains("padding-left: 20px")
|
||||||
|
),
|
||||||
|
cls="mf-treenode-container",
|
||||||
|
data_node_id=child.id
|
||||||
|
)
|
||||||
|
assert matches(child_container, child_expected)
|
||||||
|
|
||||||
|
# Test grandchild node (level 2)
|
||||||
|
grandchild_container = find_one(rendered, Div(data_node_id=grandchild.id))
|
||||||
|
grandchild_expected = Div(
|
||||||
|
Div(
|
||||||
|
Div(None), # No icon, leaf node
|
||||||
|
Span("Grandchild"),
|
||||||
|
Div(), # Action buttons
|
||||||
|
cls=Contains("mf-treenode"),
|
||||||
|
style=Contains("padding-left: 40px")
|
||||||
|
),
|
||||||
|
cls="mf-treenode-container",
|
||||||
|
data_node_id=grandchild.id
|
||||||
|
)
|
||||||
|
assert matches(grandchild_container, grandchild_expected)
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
|
||||||
|
def test_toggle_icon_has_correct_command(self, tree_view):
|
||||||
|
"""Test that toggle icon has ToggleNode command.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Div wrapper with command: mk.icon() wraps SVG in Div with HTMX attributes
|
||||||
|
- TestIcon inside Div: Verifies correct chevron icon is displayed
|
||||||
|
- TestCommand "ToggleNode": Essential for HTMX to route to correct handler
|
||||||
|
- Command targets correct node_id: Ensures the right node is toggled
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Step 1: Extract the parent node element
|
||||||
|
rendered = tree_view.render()
|
||||||
|
parent_node = find_one(rendered, Div(data_node_id=parent.id))
|
||||||
|
|
||||||
|
# Step 2: Define expected structure
|
||||||
|
expected = Div(
|
||||||
|
Div(
|
||||||
|
TestIcon("chevron_right20_regular", command=tree_view.commands.toggle_node(parent.id)),
|
||||||
|
),
|
||||||
|
data_node_id=parent.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Compare
|
||||||
|
assert matches(parent_node, expected)
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
|
||||||
|
def test_action_buttons_have_correct_commands(self, tree_view):
|
||||||
|
"""Test that action buttons have correct commands.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- add_circle icon with AddChild: Enables adding child nodes via HTMX
|
||||||
|
- edit icon with StartRename: Triggers inline editing mode
|
||||||
|
- delete icon with DeleteNode: Enables node deletion
|
||||||
|
- cls "mf-treenode-actions": Required CSS class for button container styling
|
||||||
|
"""
|
||||||
|
node = TreeNode(label="Node", type="folder")
|
||||||
|
tree_view.add_node(node)
|
||||||
|
|
||||||
|
# Step 1: Extract the action buttons container
|
||||||
|
rendered = tree_view.render()
|
||||||
|
actions = find_one(rendered, Div(cls=Contains("mf-treenode-actions")))
|
||||||
|
|
||||||
|
# Step 2: Define expected structure
|
||||||
|
expected = Div(
|
||||||
|
TestIcon("add_circle20_regular", command=tree_view.commands.add_child(node.id)),
|
||||||
|
TestIcon("edit20_regular", command=tree_view.commands.start_rename(node.id)),
|
||||||
|
TestIcon("delete20_regular", command=tree_view.commands.delete_node(node.id)),
|
||||||
|
cls=Contains("mf-treenode-actions")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Compare
|
||||||
|
assert matches(actions, expected)
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
|
||||||
|
def test_label_has_select_command(self, tree_view):
|
||||||
|
"""Test that node label has SelectNode command.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Span with node label: Displays the node text
|
||||||
|
- TestCommand "SelectNode": Clicking label selects the node via HTMX
|
||||||
|
- cls "mf-treenode-label": Required CSS class for label styling
|
||||||
|
"""
|
||||||
|
node = TreeNode(label="Clickable Node", type="file")
|
||||||
|
tree_view.add_node(node)
|
||||||
|
|
||||||
|
# Step 1: Extract the label element
|
||||||
|
rendered = tree_view.render()
|
||||||
|
label = find_one(rendered, Span(cls=Contains("mf-treenode-label")))
|
||||||
|
|
||||||
|
# Step 2: Define expected structure
|
||||||
|
expected = Span(
|
||||||
|
"Clickable Node",
|
||||||
|
command=tree_view.commands.select_node(node.id),
|
||||||
|
cls=Contains("mf-treenode-label")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Compare
|
||||||
|
assert matches(label, expected)
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
|
||||||
|
def test_input_has_save_rename_command(self, tree_view):
|
||||||
|
"""Test that editing input has SaveRename command.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Input element: Enables inline editing of node label
|
||||||
|
- TestCommand "SaveRename": Submits new label via HTMX on form submission
|
||||||
|
- name "node_label": Required for form data to include the new label value
|
||||||
|
- value with current label: Pre-fills input with existing node text
|
||||||
|
"""
|
||||||
|
node = TreeNode(label="Edit Me", type="file")
|
||||||
|
tree_view.add_node(node)
|
||||||
|
tree_view._start_rename(node.id)
|
||||||
|
|
||||||
|
# Step 1: Extract the input element
|
||||||
|
rendered = tree_view.render()
|
||||||
|
input_elem = find_one(rendered, Input(name="node_label"))
|
||||||
|
|
||||||
|
# Step 2: Define expected structure
|
||||||
|
expected = Input(
|
||||||
|
name="node_label",
|
||||||
|
value="Edit Me",
|
||||||
|
command=TestCommand(tree_view.commands.save_rename(node.id)),
|
||||||
|
cls=Contains("mf-treenode-input")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Compare
|
||||||
|
assert matches(input_elem, expected)
|
||||||
|
|
||||||
|
def test_keyboard_has_cancel_rename_command(self, tree_view):
|
||||||
|
"""Test that Keyboard component has Escape key bound to CancelRename.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- TestObject Keyboard: Verifies keyboard shortcuts component is present
|
||||||
|
- esc combination with CancelRename: Enables canceling rename with Escape key
|
||||||
|
- Essential for UX: Users expect Escape to cancel inline editing
|
||||||
|
"""
|
||||||
|
# Step 1: Extract the Keyboard component
|
||||||
|
rendered = tree_view.render()
|
||||||
|
keyboard = find_one(rendered, TestObject(Keyboard))
|
||||||
|
|
||||||
|
# Step 2: Define expected structure
|
||||||
|
expected = TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")})
|
||||||
|
|
||||||
|
# Step 3: Compare
|
||||||
|
assert matches(keyboard, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_root_nodes_are_rendered(self, tree_view):
|
||||||
|
"""Test that multiple root nodes are rendered at the same level.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Multiple root nodes: Verifies TreeView supports forest structure (multiple trees)
|
||||||
|
- All at same level: No artificial parent wrapping root nodes
|
||||||
|
- Each root has its own container: Proper structure for multiple independent trees
|
||||||
|
"""
|
||||||
|
root1 = TreeNode(label="Root 1", type="folder")
|
||||||
|
root2 = TreeNode(label="Root 2", type="folder")
|
||||||
|
|
||||||
|
tree_view.add_node(root1)
|
||||||
|
tree_view.add_node(root2)
|
||||||
|
|
||||||
|
rendered = tree_view.render()
|
||||||
|
root_containers = find(rendered, Div(cls=Contains("mf-treenode-container")))
|
||||||
|
|
||||||
|
assert len(root_containers) == 2, "Should have two root-level containers"
|
||||||
|
|
||||||
|
root1_container = find_one(rendered, Div(data_node_id=root1.id))
|
||||||
|
root2_container = find_one(rendered, Div(data_node_id=root2.id))
|
||||||
|
|
||||||
|
expected_root1 = Div(
|
||||||
|
Div(
|
||||||
|
Div(None), # No icon, leaf node
|
||||||
|
Span("Root 1"),
|
||||||
|
Div(), # Action buttons
|
||||||
|
cls=Contains("mf-treenode")
|
||||||
|
),
|
||||||
|
cls="mf-treenode-container",
|
||||||
|
data_node_id=root1.id
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_root2 = Div(
|
||||||
|
Div(
|
||||||
|
Div(None), # No icon, leaf node
|
||||||
|
Span("Root 2"),
|
||||||
|
Div(), # Action buttons
|
||||||
|
cls=Contains("mf-treenode")
|
||||||
|
),
|
||||||
|
cls="mf-treenode-container",
|
||||||
|
data_node_id=root2.id
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(root1_container, expected_root1)
|
||||||
|
assert matches(root2_container, expected_root2)
|
||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
from fasthtml.components import Button, Div
|
from fasthtml.components import Button, Div
|
||||||
from myutils.observable import make_observable, bind
|
from myutils.observable import make_observable, bind
|
||||||
|
|
||||||
from myfasthtml.core.commands import Command, CommandsManager
|
from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand
|
||||||
from myfasthtml.core.constants import ROUTE_ROOT, Routes
|
from myfasthtml.core.constants import ROUTE_ROOT, Routes
|
||||||
from myfasthtml.test.matcher import matches
|
from myfasthtml.test.matcher import matches
|
||||||
|
|
||||||
@@ -183,3 +183,14 @@ class TestCommandExecute:
|
|||||||
assert "hx-swap-oob" not in res[0].attrs
|
assert "hx-swap-oob" not in res[0].attrs
|
||||||
assert "hx-swap-oob" not in res[1].attrs
|
assert "hx-swap-oob" not in res[1].attrs
|
||||||
assert "hx-swap-oob" not in res[3].attrs
|
assert "hx-swap-oob" not in res[3].attrs
|
||||||
|
|
||||||
|
|
||||||
|
class TestLambaCommand:
|
||||||
|
|
||||||
|
def test_i_can_create_a_command_from_lambda(self):
|
||||||
|
command = LambdaCommand(lambda resp: "Hello World")
|
||||||
|
assert command.execute() == "Hello World"
|
||||||
|
|
||||||
|
def test_by_default_target_is_none(self):
|
||||||
|
command = LambdaCommand(lambda resp: "Hello World")
|
||||||
|
assert command.get_htmx_params()["hx-swap"] == "none"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from myfasthtml.core.dbmanager import DbManager, DbObject
|
from myfasthtml.core.dbmanager import DbManager, DbObject
|
||||||
|
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -19,9 +20,14 @@ def session():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db_manager(session):
|
def parent(session):
|
||||||
|
return SingleInstance(session=session, _id="test_parent_id")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_manager(parent):
|
||||||
shutil.rmtree("TestDb", ignore_errors=True)
|
shutil.rmtree("TestDb", ignore_errors=True)
|
||||||
db_manager_instance = DbManager(session, root="TestDb", auto_register=False)
|
db_manager_instance = DbManager(parent, root="TestDb", auto_register=False)
|
||||||
|
|
||||||
yield db_manager_instance
|
yield db_manager_instance
|
||||||
|
|
||||||
@@ -32,17 +38,17 @@ def simplify(res: dict) -> dict:
|
|||||||
return {k: v for k, v in res.items() if not k.startswith("_")}
|
return {k: v for k, v in res.items() if not k.startswith("_")}
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init(session, db_manager):
|
def test_i_can_init(parent, db_manager):
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.value: str = "hello"
|
self.value: str = "hello"
|
||||||
self.number: int = 42
|
self.number: int = 42
|
||||||
self.none_value: None = None
|
self.none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
props = dummy._get_properties()
|
props = dummy._get_properties()
|
||||||
|
|
||||||
@@ -52,17 +58,17 @@ def test_i_can_init(session, db_manager):
|
|||||||
assert len(history) == 1
|
assert len(history) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init_from_dataclass(session, db_manager):
|
def test_i_can_init_from_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
none_value: None = None
|
none_value: None = None
|
||||||
|
|
||||||
DummyObject(session)
|
DummyObject(parent)
|
||||||
|
|
||||||
in_db = db_manager.load("DummyObject")
|
in_db = db_manager.load("DummyObject")
|
||||||
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
||||||
@@ -70,10 +76,10 @@ def test_i_can_init_from_dataclass(session, db_manager):
|
|||||||
assert len(history) == 1
|
assert len(history) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init_from_db_with(session, db_manager):
|
def test_i_can_init_from_db_with(parent, db_manager):
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.value: str = "hello"
|
self.value: str = "hello"
|
||||||
@@ -82,17 +88,17 @@ def test_i_can_init_from_db_with(session, db_manager):
|
|||||||
# insert other values in db
|
# insert other values in db
|
||||||
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
assert dummy.value == "other_value"
|
assert dummy.value == "other_value"
|
||||||
assert dummy.number == 34
|
assert dummy.number == 34
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_init_from_db_with_dataclass(session, db_manager):
|
def test_i_can_init_from_db_with_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
@@ -100,16 +106,16 @@ def test_i_can_init_from_db_with_dataclass(session, db_manager):
|
|||||||
# insert other values in db
|
# insert other values in db
|
||||||
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
assert dummy.value == "other_value"
|
assert dummy.value == "other_value"
|
||||||
assert dummy.number == 34
|
assert dummy.number == 34
|
||||||
|
|
||||||
|
|
||||||
def test_i_do_not_save_when_prefixed_by_underscore_or_ns(session, db_manager):
|
def test_i_do_not_save_when_prefixed_by_underscore_or_ns(parent, db_manager):
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.to_save: str = "value"
|
self.to_save: str = "value"
|
||||||
@@ -120,7 +126,7 @@ def test_i_do_not_save_when_prefixed_by_underscore_or_ns(session, db_manager):
|
|||||||
_not_to_save: str = "value"
|
_not_to_save: str = "value"
|
||||||
ns_not_to_save: str = "value"
|
ns_not_to_save: str = "value"
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
dummy.to_save = "other_value"
|
dummy.to_save = "other_value"
|
||||||
dummy.ns_not_to_save = "other_value"
|
dummy.ns_not_to_save = "other_value"
|
||||||
dummy._not_to_save = "other_value"
|
dummy._not_to_save = "other_value"
|
||||||
@@ -131,17 +137,17 @@ def test_i_do_not_save_when_prefixed_by_underscore_or_ns(session, db_manager):
|
|||||||
assert "ns_not_to_save" not in in_db
|
assert "ns_not_to_save" not in in_db
|
||||||
|
|
||||||
|
|
||||||
def test_i_do_not_save_when_prefixed_by_underscore_or_ns_with_dataclass(session, db_manager):
|
def test_i_do_not_save_when_prefixed_by_underscore_or_ns_with_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
to_save: str = "value"
|
to_save: str = "value"
|
||||||
_not_to_save: str = "value"
|
_not_to_save: str = "value"
|
||||||
ns_not_to_save: str = "value"
|
ns_not_to_save: str = "value"
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
dummy.to_save = "other_value"
|
dummy.to_save = "other_value"
|
||||||
dummy.ns_not_to_save = "other_value"
|
dummy.ns_not_to_save = "other_value"
|
||||||
dummy._not_to_save = "other_value"
|
dummy._not_to_save = "other_value"
|
||||||
@@ -152,31 +158,31 @@ def test_i_do_not_save_when_prefixed_by_underscore_or_ns_with_dataclass(session,
|
|||||||
assert "ns_not_to_save" not in in_db
|
assert "ns_not_to_save" not in in_db
|
||||||
|
|
||||||
|
|
||||||
def test_db_is_updated_when_attribute_is_modified(session, db_manager):
|
def test_db_is_updated_when_attribute_is_modified(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
dummy.value = "other_value"
|
dummy.value = "other_value"
|
||||||
|
|
||||||
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 42}
|
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 42}
|
||||||
|
|
||||||
|
|
||||||
def test_i_do_not_save_in_db_when_value_is_the_same(session, db_manager):
|
def test_i_do_not_save_in_db_when_value_is_the_same(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
dummy.value = "other_value"
|
dummy.value = "other_value"
|
||||||
in_db_1 = db_manager.load("DummyObject")
|
in_db_1 = db_manager.load("DummyObject")
|
||||||
|
|
||||||
@@ -186,16 +192,16 @@ def test_i_do_not_save_in_db_when_value_is_the_same(session, db_manager):
|
|||||||
assert in_db_1["__parent__"] == in_db_2["__parent__"]
|
assert in_db_1["__parent__"] == in_db_2["__parent__"]
|
||||||
|
|
||||||
|
|
||||||
def test_i_can_update(session, db_manager):
|
def test_i_can_update(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
clone = dummy.copy()
|
clone = dummy.copy()
|
||||||
|
|
||||||
clone.number = 34
|
clone.number = 34
|
||||||
@@ -207,54 +213,52 @@ def test_i_can_update(session, db_manager):
|
|||||||
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
|
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
|
||||||
|
|
||||||
|
|
||||||
def test_forbidden_attributes_are_not_the_copy(session, db_manager):
|
def test_forbidden_attributes_are_not_the_copy(parent, db_manager):
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.value: str = "hello"
|
self.value: str = "hello"
|
||||||
self.number: int = 42
|
self.number: int = 42
|
||||||
self.none_value: None = None
|
self.none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
clone = dummy.copy()
|
clone = dummy.copy()
|
||||||
|
|
||||||
for k in DbObject._forbidden_attrs:
|
for k in DbObject._forbidden_attrs:
|
||||||
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||||
|
|
||||||
|
|
||||||
def test_forbidden_attributes_are_not_the_copy_for_dataclass(session, db_manager):
|
def test_forbidden_attributes_are_not_the_copy_for_dataclass(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
none_value: None = None
|
none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
clone = dummy.copy()
|
clone = dummy.copy()
|
||||||
|
|
||||||
for k in DbObject._forbidden_attrs:
|
for k in DbObject._forbidden_attrs:
|
||||||
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||||
|
|
||||||
|
|
||||||
def test_i_cannot_update_a_forbidden_attribute(session, db_manager):
|
def test_i_cannot_update_a_forbidden_attribute(parent, db_manager):
|
||||||
@dataclass
|
@dataclass
|
||||||
class DummyObject(DbObject):
|
class DummyObject(DbObject):
|
||||||
def __init__(self, sess: dict):
|
def __init__(self, owner: BaseInstance):
|
||||||
super().__init__(sess, "DummyObject", db_manager)
|
super().__init__(owner, "DummyObject", db_manager)
|
||||||
|
|
||||||
value: str = "hello"
|
value: str = "hello"
|
||||||
number: int = 42
|
number: int = 42
|
||||||
none_value: None = None
|
none_value: None = None
|
||||||
|
|
||||||
dummy = DummyObject(session)
|
dummy = DummyObject(parent)
|
||||||
|
|
||||||
dummy.update(_session="other_value")
|
dummy.update(_owner="other_value")
|
||||||
|
|
||||||
assert dummy._session == session
|
assert dummy._owner is parent
|
||||||
|
|||||||
399
tests/core/test_instances.py
Normal file
399
tests/core/test_instances.py
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from myfasthtml.core.instances import (
|
||||||
|
BaseInstance,
|
||||||
|
SingleInstance,
|
||||||
|
MultipleInstance,
|
||||||
|
InstancesManager,
|
||||||
|
DuplicateInstanceError,
|
||||||
|
special_session,
|
||||||
|
Ids,
|
||||||
|
RootInstance
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_instances():
|
||||||
|
"""Reset instances before each test to ensure isolation."""
|
||||||
|
InstancesManager.instances.clear()
|
||||||
|
yield
|
||||||
|
InstancesManager.instances.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session():
|
||||||
|
"""Create a test session."""
|
||||||
|
return {"user_info": {"id": "test-user-123"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def another_session():
|
||||||
|
"""Create another test session."""
|
||||||
|
return {"user_info": {"id": "test-user-456"}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def root_instance(session):
|
||||||
|
"""Create a root instance for testing."""
|
||||||
|
return SingleInstance(parent=None, session=session, _id="test-root")
|
||||||
|
|
||||||
|
|
||||||
|
# Example subclasses for testing
|
||||||
|
class SubSingleInstance(SingleInstance):
|
||||||
|
"""Example subclass of SingleInstance with simplified signature."""
|
||||||
|
|
||||||
|
def __init__(self, parent):
|
||||||
|
super().__init__(parent=parent)
|
||||||
|
|
||||||
|
|
||||||
|
class SubMultipleInstance(MultipleInstance):
|
||||||
|
"""Example subclass of MultipleInstance with custom parameter."""
|
||||||
|
|
||||||
|
def __init__(self, parent, _id=None, custom_param=None):
|
||||||
|
super().__init__(parent=parent, _id=_id)
|
||||||
|
self.custom_param = custom_param
|
||||||
|
|
||||||
|
|
||||||
|
class TestBaseInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_a_base_instance_with_positional_args(self, session, root_instance):
|
||||||
|
"""Test that a BaseInstance can be created with positional arguments."""
|
||||||
|
instance = BaseInstance(root_instance, session, "test_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "test_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_create_a_base_instance_with_kwargs(self, session, root_instance):
|
||||||
|
"""Test that a BaseInstance can be created with keyword arguments."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session, _id="test_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "test_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_create_a_base_instance_with_mixed_args(self, session, root_instance):
|
||||||
|
"""Test that a BaseInstance can be created with mixed positional and keyword arguments."""
|
||||||
|
instance = BaseInstance(root_instance, session=session, _id="test_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "test_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_retrieve_the_same_instance_when_using_same_session_and_id(self, session, root_instance):
|
||||||
|
"""Test that creating an instance with same session and id returns the existing instance."""
|
||||||
|
instance1 = BaseInstance(root_instance, session, "same_id")
|
||||||
|
instance2 = BaseInstance(root_instance, session, "same_id")
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_can_control_instances_registration(self, session, root_instance):
|
||||||
|
"""Test that auto_register=False prevents automatic registration."""
|
||||||
|
BaseInstance(parent=root_instance, session=session, _id="test_id", auto_register=False)
|
||||||
|
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
key = (session_id, "test_id")
|
||||||
|
|
||||||
|
assert key not in InstancesManager.instances
|
||||||
|
|
||||||
|
def test_i_can_have_different_instances_for_different_sessions(self, session, another_session, root_instance):
|
||||||
|
"""Test that different sessions can have instances with the same id."""
|
||||||
|
root_instance2 = SingleInstance(parent=None, session=another_session, _id="test-root")
|
||||||
|
|
||||||
|
instance1 = BaseInstance(root_instance, session, "same_id")
|
||||||
|
instance2 = BaseInstance(root_instance2, another_session, "same_id")
|
||||||
|
|
||||||
|
assert instance1 is not instance2
|
||||||
|
assert instance1.get_session() == session
|
||||||
|
assert instance2.get_session() == another_session
|
||||||
|
|
||||||
|
def test_i_can_create_instance_with_parent_only(self, session, root_instance):
|
||||||
|
"""Test that session can be extracted from parent when not provided."""
|
||||||
|
instance = BaseInstance(parent=root_instance, _id="test_id")
|
||||||
|
|
||||||
|
assert instance.get_session() == root_instance.get_session()
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_cannot_create_instance_without_parent_or_session(self):
|
||||||
|
"""Test that creating an instance without parent or session raises TypeError."""
|
||||||
|
with pytest.raises(TypeError, match="Either session or parent must be provided"):
|
||||||
|
BaseInstance(None, _id="test_id")
|
||||||
|
|
||||||
|
def test_i_can_get_auto_generated_id(self, session, root_instance):
|
||||||
|
"""Test that if _id is not provided, an ID is auto-generated via compute_id()."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session)
|
||||||
|
|
||||||
|
assert instance.get_id() is not None
|
||||||
|
assert instance.get_id().startswith("mf-base_instance-")
|
||||||
|
|
||||||
|
def test_i_can_get_prefix_from_class_name(self, session):
|
||||||
|
"""Test that get_prefix() returns the correct snake_case prefix."""
|
||||||
|
prefix = BaseInstance(None, session).get_prefix()
|
||||||
|
|
||||||
|
assert prefix == "mf-base_instance"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_a_single_instance(self, session, root_instance):
|
||||||
|
"""Test that a SingleInstance can be created."""
|
||||||
|
instance = SingleInstance(parent=root_instance, session=session)
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "mf-single_instance"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_i_can_create_single_instance_with_positional_args(self, session, root_instance):
|
||||||
|
"""Test that a SingleInstance can be created with positional arguments."""
|
||||||
|
instance = SingleInstance(root_instance, session, "custom_id")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert instance.get_id() == "custom_id"
|
||||||
|
assert instance.get_session() == session
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
|
||||||
|
def test_the_same_instance_is_returned(self, session):
|
||||||
|
"""Test that single instance is cached and returned on subsequent calls."""
|
||||||
|
instance1 = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||||
|
instance2 = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_cannot_create_duplicate_single_instance(self, session):
|
||||||
|
"""Test that creating a duplicate SingleInstance raises DuplicateInstanceError."""
|
||||||
|
instance = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||||
|
|
||||||
|
with pytest.raises(DuplicateInstanceError):
|
||||||
|
InstancesManager.register(session, instance)
|
||||||
|
|
||||||
|
def test_i_can_retrieve_existing_single_instance(self, session):
|
||||||
|
"""Test that attempting to create an existing SingleInstance returns the same instance."""
|
||||||
|
instance1 = SingleInstance(parent=None, session=session, _id="same_id")
|
||||||
|
instance2 = SingleInstance(parent=None, session=session, _id="same_id", auto_register=False)
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_can_get_auto_computed_id_for_single_instance(self, session):
|
||||||
|
"""Test that the default ID equals prefix for SingleInstance."""
|
||||||
|
instance = SingleInstance(parent=None, session=session)
|
||||||
|
|
||||||
|
assert instance.get_id() == "mf-single_instance"
|
||||||
|
assert instance.get_prefix() == "mf-single_instance"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleInstanceSubclass:
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_of_single_instance(self, root_instance):
|
||||||
|
"""Test that a subclass of SingleInstance works correctly."""
|
||||||
|
instance = SubSingleInstance(root_instance)
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert isinstance(instance, SingleInstance)
|
||||||
|
assert isinstance(instance, SubSingleInstance)
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
|
||||||
|
"""Test that subclass with simplified signature works correctly."""
|
||||||
|
instance = SubSingleInstance(root_instance)
|
||||||
|
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
assert instance.get_session() == root_instance.get_session()
|
||||||
|
assert instance.get_id() == "mf-sub_single_instance"
|
||||||
|
assert instance.get_prefix() == "mf-sub_single_instance"
|
||||||
|
|
||||||
|
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
|
||||||
|
"""Test that cache works for subclasses."""
|
||||||
|
instance1 = SubSingleInstance(root_instance)
|
||||||
|
instance2 = SubSingleInstance(root_instance)
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultipleInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_multiple_instances_with_same_prefix(self, session, root_instance):
|
||||||
|
"""Test that multiple MultipleInstance objects can be created with the same prefix."""
|
||||||
|
instance1 = MultipleInstance(parent=root_instance, session=session)
|
||||||
|
instance2 = MultipleInstance(parent=root_instance, session=session)
|
||||||
|
|
||||||
|
assert instance1 is not instance2
|
||||||
|
assert instance1.get_id() != instance2.get_id()
|
||||||
|
assert instance1.get_id().startswith("mf-multiple_instance-")
|
||||||
|
assert instance2.get_id().startswith("mf-multiple_instance-")
|
||||||
|
|
||||||
|
def test_i_can_have_auto_generated_unique_ids(self, session, root_instance):
|
||||||
|
"""Test that each MultipleInstance receives a unique auto-generated ID."""
|
||||||
|
instances = [MultipleInstance(parent=root_instance, session=session) for _ in range(5)]
|
||||||
|
ids = [inst.get_id() for inst in instances]
|
||||||
|
|
||||||
|
# All IDs should be unique
|
||||||
|
assert len(ids) == len(set(ids))
|
||||||
|
|
||||||
|
# All IDs should start with the prefix
|
||||||
|
assert all(id.startswith("mf-multiple_instance-") for id in ids)
|
||||||
|
|
||||||
|
def test_i_can_provide_custom_id_to_multiple_instance(self, session, root_instance):
|
||||||
|
"""Test that a custom _id can be provided to MultipleInstance."""
|
||||||
|
custom_id = "custom-instance-id"
|
||||||
|
instance = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||||
|
|
||||||
|
assert instance.get_id() == custom_id
|
||||||
|
|
||||||
|
def test_i_can_retrieve_multiple_instance_by_custom_id(self, session, root_instance):
|
||||||
|
"""Test that a MultipleInstance with custom _id can be retrieved from cache."""
|
||||||
|
custom_id = "custom-instance-id"
|
||||||
|
instance1 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||||
|
instance2 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_key_prefixed_by_underscore_uses_the_parent_id_as_prefix(self, root_instance):
|
||||||
|
"""Test that key prefixed by underscore uses the parent id as prefix."""
|
||||||
|
instance = MultipleInstance(parent=root_instance, _id="-test_id")
|
||||||
|
|
||||||
|
assert instance.get_id() == f"{root_instance.get_id()}-test_id"
|
||||||
|
|
||||||
|
def test_no_parent_id_as_prefix_if_parent_is_none(self, session, root_instance):
|
||||||
|
"""Test that key prefixed by underscore does not use the parent id as prefix if parent is None."""
|
||||||
|
instance = MultipleInstance(parent=None, session=session, _id="-test_id")
|
||||||
|
|
||||||
|
assert instance.get_id() == "-test_id"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultipleInstanceSubclass:
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_of_multiple_instance(self, root_instance):
|
||||||
|
"""Test that a subclass of MultipleInstance works correctly."""
|
||||||
|
instance = SubMultipleInstance(root_instance, custom_param="test")
|
||||||
|
|
||||||
|
assert instance is not None
|
||||||
|
assert isinstance(instance, MultipleInstance)
|
||||||
|
assert isinstance(instance, SubMultipleInstance)
|
||||||
|
assert instance.custom_param == "test"
|
||||||
|
|
||||||
|
def test_i_can_create_multiple_subclass_instances_with_auto_generated_ids(self, root_instance):
|
||||||
|
"""Test that multiple instances of subclass can be created with unique IDs."""
|
||||||
|
instance1 = SubMultipleInstance(root_instance, custom_param="first")
|
||||||
|
instance2 = SubMultipleInstance(root_instance, custom_param="second")
|
||||||
|
|
||||||
|
assert instance1 is not instance2
|
||||||
|
assert instance1.get_id() != instance2.get_id()
|
||||||
|
assert instance1.get_id().startswith("mf-sub_multiple_instance-")
|
||||||
|
assert instance2.get_id().startswith("mf-sub_multiple_instance-")
|
||||||
|
|
||||||
|
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
|
||||||
|
"""Test that subclass with custom parameters works correctly."""
|
||||||
|
instance = SubMultipleInstance(root_instance, custom_param="value")
|
||||||
|
|
||||||
|
assert instance.get_parent() == root_instance
|
||||||
|
assert instance.get_session() == root_instance.get_session()
|
||||||
|
assert instance.custom_param == "value"
|
||||||
|
|
||||||
|
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
|
||||||
|
"""Test that cache works for subclasses."""
|
||||||
|
instance1 = SubMultipleInstance(root_instance, custom_param="first")
|
||||||
|
instance2 = SubMultipleInstance(root_instance, custom_param="second", _id=instance1.get_id())
|
||||||
|
|
||||||
|
assert instance1 is instance2
|
||||||
|
|
||||||
|
def test_i_cannot_retrieve_subclass_instance_when_type_differs(self, root_instance):
|
||||||
|
"""Test that cache works for subclasses with custom _id."""
|
||||||
|
# Need to pass _id explicitly to enable caching
|
||||||
|
instance1 = SubMultipleInstance(root_instance)
|
||||||
|
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
MultipleInstance(parent=root_instance, _id=instance1.get_id())
|
||||||
|
|
||||||
|
def test_i_can_get_correct_prefix_for_multiple_subclass(self, root_instance):
|
||||||
|
"""Test that subclass has correct auto-generated prefix."""
|
||||||
|
prefix = SubMultipleInstance(root_instance).get_prefix()
|
||||||
|
|
||||||
|
assert prefix == "mf-sub_multiple_instance"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstancesManager:
|
||||||
|
|
||||||
|
def test_i_can_register_an_instance_manually(self, session, root_instance):
|
||||||
|
"""Test that an instance can be manually registered."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session, _id="manual_id", auto_register=False)
|
||||||
|
|
||||||
|
InstancesManager.register(session, instance)
|
||||||
|
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
key = (session_id, "manual_id")
|
||||||
|
|
||||||
|
assert key in InstancesManager.instances
|
||||||
|
assert InstancesManager.instances[key] is instance
|
||||||
|
|
||||||
|
def test_i_can_get_existing_instance_by_id(self, session, root_instance):
|
||||||
|
"""Test that an existing instance can be retrieved by ID."""
|
||||||
|
instance = BaseInstance(parent=root_instance, session=session, _id="get_id")
|
||||||
|
|
||||||
|
retrieved = InstancesManager.get(session, "get_id")
|
||||||
|
|
||||||
|
assert retrieved is instance
|
||||||
|
|
||||||
|
def test_i_cannot_get_nonexistent_instance_without_type(self, session):
|
||||||
|
"""Test that getting a non-existent instance without type raises KeyError."""
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
InstancesManager.get(session, "nonexistent_id")
|
||||||
|
|
||||||
|
def test_i_can_get_session_id_from_valid_session(self, session):
|
||||||
|
"""Test that session ID is correctly extracted from a valid session."""
|
||||||
|
session_id = InstancesManager.get_session_id(session)
|
||||||
|
|
||||||
|
assert session_id == "test-user-123"
|
||||||
|
|
||||||
|
def test_i_can_handle_none_session(self):
|
||||||
|
"""Test that None session returns a special identifier."""
|
||||||
|
session_id = InstancesManager.get_session_id(None)
|
||||||
|
|
||||||
|
assert session_id == "** NOT LOGGED IN **"
|
||||||
|
|
||||||
|
def test_i_can_handle_invalid_session(self):
|
||||||
|
"""Test that invalid sessions return appropriate identifiers."""
|
||||||
|
# Session is None
|
||||||
|
session_id = InstancesManager.get_session_id(None)
|
||||||
|
assert session_id == "** NOT LOGGED IN **"
|
||||||
|
|
||||||
|
# Session without user_info
|
||||||
|
session_no_user = {}
|
||||||
|
session_id = InstancesManager.get_session_id(session_no_user)
|
||||||
|
assert session_id == "** UNKNOWN USER **"
|
||||||
|
|
||||||
|
# Session with user_info but no id
|
||||||
|
session_no_id = {"user_info": {}}
|
||||||
|
session_id = InstancesManager.get_session_id(session_no_id)
|
||||||
|
assert session_id == "** INVALID SESSION **"
|
||||||
|
|
||||||
|
def test_i_can_reset_all_instances(self, session, root_instance):
|
||||||
|
"""Test that reset() clears all instances."""
|
||||||
|
BaseInstance(parent=root_instance, session=session, _id="id1")
|
||||||
|
BaseInstance(parent=root_instance, session=session, _id="id2")
|
||||||
|
|
||||||
|
assert len(InstancesManager.instances) > 0
|
||||||
|
|
||||||
|
InstancesManager.reset()
|
||||||
|
|
||||||
|
assert len(InstancesManager.instances) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRootInstance:
|
||||||
|
|
||||||
|
def test_i_can_create_root_instance_with_positional_args(self):
|
||||||
|
"""Test that RootInstance can be created with positional arguments."""
|
||||||
|
root = SingleInstance(None, special_session, Ids.Root)
|
||||||
|
|
||||||
|
assert root is not None
|
||||||
|
assert root.get_id() == Ids.Root
|
||||||
|
assert root.get_session() == special_session
|
||||||
|
assert root.get_parent() is None
|
||||||
|
|
||||||
|
def test_i_can_access_root_instance(self):
|
||||||
|
"""Test that RootInstance is created and accessible."""
|
||||||
|
assert RootInstance is not None
|
||||||
|
assert RootInstance.get_id() == Ids.Root
|
||||||
|
assert RootInstance.get_session() == special_session
|
||||||
@@ -311,7 +311,7 @@ class TestFromParentChildList:
|
|||||||
nodes, edges = from_parent_child_list(items)
|
nodes, edges = from_parent_child_list(items)
|
||||||
|
|
||||||
assert len(nodes) == 1
|
assert len(nodes) == 1
|
||||||
assert nodes[0] == {"id": "root", "label": "Root"}
|
assert nodes[0] == {'color': '#ff9999', 'id': 'root', 'label': 'Root'}
|
||||||
assert len(edges) == 0
|
assert len(edges) == 0
|
||||||
|
|
||||||
def test_i_can_convert_simple_parent_child_relationship(self):
|
def test_i_can_convert_simple_parent_child_relationship(self):
|
||||||
@@ -323,7 +323,7 @@ class TestFromParentChildList:
|
|||||||
nodes, edges = from_parent_child_list(items)
|
nodes, edges = from_parent_child_list(items)
|
||||||
|
|
||||||
assert len(nodes) == 2
|
assert len(nodes) == 2
|
||||||
assert {"id": "root", "label": "Root"} in nodes
|
assert {'color': '#ff9999', 'id': 'root', 'label': 'Root'} in nodes
|
||||||
assert {"id": "child", "label": "Child"} in nodes
|
assert {"id": "child", "label": "Child"} in nodes
|
||||||
|
|
||||||
assert len(edges) == 1
|
assert len(edges) == 1
|
||||||
@@ -405,7 +405,7 @@ class TestFromParentChildList:
|
|||||||
|
|
||||||
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
|
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
|
||||||
assert "color" in ghost_node
|
assert "color" in ghost_node
|
||||||
assert ghost_node["color"] == "#ff9999"
|
assert ghost_node["color"] == "#cccccc"
|
||||||
|
|
||||||
def test_i_can_use_custom_ghost_color(self):
|
def test_i_can_use_custom_ghost_color(self):
|
||||||
"""Test that custom ghost_color parameter is applied."""
|
"""Test that custom ghost_color parameter is applied."""
|
||||||
@@ -513,3 +513,136 @@ class TestFromParentChildList:
|
|||||||
|
|
||||||
ghost_node = [n for n in nodes if n["id"] == "ghost_parent"][0]
|
ghost_node = [n for n in nodes if n["id"] == "ghost_parent"][0]
|
||||||
assert ghost_node["label"] == "ghost_parent"
|
assert ghost_node["label"] == "ghost_parent"
|
||||||
|
|
||||||
|
def test_i_can_apply_root_color_to_single_root(self):
|
||||||
|
"""Test that a single root node receives the root_color."""
|
||||||
|
items = [{"id": "root", "label": "Root"}]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#ff0000")
|
||||||
|
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert nodes[0]["color"] == "#ff0000"
|
||||||
|
|
||||||
|
def test_i_can_apply_root_color_to_multiple_roots(self):
|
||||||
|
"""Test root_color is assigned to all nodes without parent."""
|
||||||
|
items = [
|
||||||
|
{"id": "root1", "label": "Root 1"},
|
||||||
|
{"id": "root2", "label": "Root 2"},
|
||||||
|
{"id": "child", "parent": "root1", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#aa0000")
|
||||||
|
|
||||||
|
root_nodes = [n for n in nodes if n["id"] in ("root1", "root2")]
|
||||||
|
assert all(n.get("color") == "#aa0000" for n in root_nodes)
|
||||||
|
|
||||||
|
# child must NOT have root_color
|
||||||
|
child_node = next(n for n in nodes if n["id"] == "child")
|
||||||
|
assert "color" not in child_node
|
||||||
|
|
||||||
|
def test_i_can_handle_root_with_parent_none(self):
|
||||||
|
"""Test that root_color is applied when parent=None."""
|
||||||
|
items = [
|
||||||
|
{"id": "r1", "parent": None, "label": "R1"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#112233")
|
||||||
|
|
||||||
|
assert nodes[0]["color"] == "#112233"
|
||||||
|
|
||||||
|
def test_i_can_handle_root_with_parent_empty_string(self):
|
||||||
|
"""Test that root_color is applied when parent=''."""
|
||||||
|
items = [
|
||||||
|
{"id": "r1", "parent": "", "label": "R1"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#334455")
|
||||||
|
|
||||||
|
assert nodes[0]["color"] == "#334455"
|
||||||
|
|
||||||
|
def test_i_do_not_apply_root_color_to_non_roots(self):
|
||||||
|
"""Test that only real roots receive root_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "root", "label": "Root"},
|
||||||
|
{"id": "child", "parent": "root", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#ff0000")
|
||||||
|
|
||||||
|
# Only one root → only this one has the color
|
||||||
|
root_node = next(n for n in nodes if n["id"] == "root")
|
||||||
|
assert root_node["color"] == "#ff0000"
|
||||||
|
|
||||||
|
child_node = next(n for n in nodes if n["id"] == "child")
|
||||||
|
assert "color" not in child_node
|
||||||
|
|
||||||
|
def test_i_do_not_override_ghost_color_with_root_color(self):
|
||||||
|
"""Ghost nodes must keep ghost_color, not root_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "child", "parent": "ghost_parent", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(
|
||||||
|
items,
|
||||||
|
root_color="#ff0000",
|
||||||
|
ghost_color="#00ff00"
|
||||||
|
)
|
||||||
|
|
||||||
|
ghost_node = next(n for n in nodes if n["id"] == "ghost_parent")
|
||||||
|
assert ghost_node["color"] == "#00ff00"
|
||||||
|
|
||||||
|
# child is not root → no color
|
||||||
|
child_node = next(n for n in nodes if n["id"] == "child")
|
||||||
|
assert "color" not in child_node
|
||||||
|
|
||||||
|
def test_i_can_use_custom_root_color(self):
|
||||||
|
"""Test that a custom root_color is applied instead of default."""
|
||||||
|
items = [{"id": "root", "label": "Root"}]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color="#123456")
|
||||||
|
|
||||||
|
assert nodes[0]["color"] == "#123456"
|
||||||
|
|
||||||
|
def test_i_can_mix_root_nodes_and_ghost_nodes(self):
|
||||||
|
"""Ensure root_color applies only to roots and ghost nodes keep ghost_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "root", "label": "Root"},
|
||||||
|
{"id": "child", "parent": "ghost_parent", "label": "Child"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(
|
||||||
|
items,
|
||||||
|
root_color="#ff0000",
|
||||||
|
ghost_color="#00ff00"
|
||||||
|
)
|
||||||
|
|
||||||
|
root_node = next(n for n in nodes if n["id"] == "root")
|
||||||
|
ghost_node = next(n for n in nodes if n["id"] == "ghost_parent")
|
||||||
|
|
||||||
|
assert root_node["color"] == "#ff0000"
|
||||||
|
assert ghost_node["color"] == "#00ff00"
|
||||||
|
|
||||||
|
def test_i_do_not_mark_node_as_root_if_parent_field_exists(self):
|
||||||
|
"""Node with parent key but non-empty value should NOT get root_color."""
|
||||||
|
items = [
|
||||||
|
{"id": "root", "label": "Root"},
|
||||||
|
{"id": "child", "parent": "root", "label": "Child"},
|
||||||
|
{"id": "other", "parent": "unknown_parent", "label": "Other"}
|
||||||
|
]
|
||||||
|
nodes, edges = from_parent_child_list(
|
||||||
|
items,
|
||||||
|
root_color="#ff0000",
|
||||||
|
ghost_color="#00ff00"
|
||||||
|
)
|
||||||
|
|
||||||
|
# "root" is the only real root
|
||||||
|
root_node = next(n for n in nodes if n["id"] == "root")
|
||||||
|
assert root_node["color"] == "#ff0000"
|
||||||
|
|
||||||
|
# "other" is NOT root, even though its parent is missing
|
||||||
|
other_node = next(n for n in nodes if n["id"] == "other")
|
||||||
|
assert "color" not in other_node
|
||||||
|
|
||||||
|
# ghost parent must have ghost_color
|
||||||
|
ghost_node = next(n for n in nodes if n["id"] == "unknown_parent")
|
||||||
|
assert ghost_node["color"] == "#00ff00"
|
||||||
|
|
||||||
|
def test_i_do_no_add_root_color_when_its_none(self):
|
||||||
|
"""Test that a single root node receives the root_color."""
|
||||||
|
items = [{"id": "root", "label": "Root"}]
|
||||||
|
nodes, edges = from_parent_child_list(items, root_color=None)
|
||||||
|
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert "color" not in nodes[0]
|
||||||
|
|||||||
449
tests/html/keyboard_support.js
Normal file
449
tests/html/keyboard_support.js
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
/**
|
||||||
|
* Create keyboard bindings
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
/**
|
||||||
|
* Global registry to store keyboard shortcuts for multiple elements
|
||||||
|
*/
|
||||||
|
const KeyboardRegistry = {
|
||||||
|
elements: new Map(), // elementId -> { tree, element }
|
||||||
|
listenerAttached: false,
|
||||||
|
currentKeys: new Set(),
|
||||||
|
snapshotHistory: [],
|
||||||
|
pendingTimeout: null,
|
||||||
|
pendingMatches: [], // Array of matches waiting for timeout
|
||||||
|
sequenceTimeout: 500 // 500ms timeout for sequences
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize key names to lowercase for case-insensitive comparison
|
||||||
|
* @param {string} key - The key to normalize
|
||||||
|
* @returns {string} - Normalized key name
|
||||||
|
*/
|
||||||
|
function normalizeKey(key) {
|
||||||
|
const keyMap = {
|
||||||
|
'control': 'ctrl',
|
||||||
|
'escape': 'esc',
|
||||||
|
'delete': 'del'
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = key.toLowerCase();
|
||||||
|
return keyMap[normalized] || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique string key from a Set of keys for Map indexing
|
||||||
|
* @param {Set} keySet - Set of normalized keys
|
||||||
|
* @returns {string} - Sorted string representation
|
||||||
|
*/
|
||||||
|
function setToKey(keySet) {
|
||||||
|
return Array.from(keySet).sort().join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single element (can be a single key or a simultaneous combination)
|
||||||
|
* @param {string} element - The element string (e.g., "a" or "Ctrl+C")
|
||||||
|
* @returns {Set} - Set of normalized keys
|
||||||
|
*/
|
||||||
|
function parseElement(element) {
|
||||||
|
if (element.includes('+')) {
|
||||||
|
// Simultaneous combination
|
||||||
|
return new Set(element.split('+').map(k => normalizeKey(k.trim())));
|
||||||
|
}
|
||||||
|
// Single key
|
||||||
|
return new Set([normalizeKey(element.trim())]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a combination string into sequence elements
|
||||||
|
* @param {string} combination - The combination string (e.g., "Ctrl+C C" or "A B C")
|
||||||
|
* @returns {Array} - Array of Sets representing the sequence
|
||||||
|
*/
|
||||||
|
function parseCombination(combination) {
|
||||||
|
// Check if it's a sequence (contains space)
|
||||||
|
if (combination.includes(' ')) {
|
||||||
|
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single element (can be a key or simultaneous combination)
|
||||||
|
return [parseElement(combination)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tree node
|
||||||
|
* @returns {Object} - New tree node
|
||||||
|
*/
|
||||||
|
function createTreeNode() {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
combinationStr: null,
|
||||||
|
children: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a tree from combinations
|
||||||
|
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||||
|
* @returns {Object} - Root tree node
|
||||||
|
*/
|
||||||
|
function buildTree(combinations) {
|
||||||
|
const root = createTreeNode();
|
||||||
|
|
||||||
|
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||||
|
const sequence = parseCombination(combinationStr);
|
||||||
|
let currentNode = root;
|
||||||
|
|
||||||
|
for (const keySet of sequence) {
|
||||||
|
const key = setToKey(keySet);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
currentNode.children.set(key, createTreeNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as end of sequence and store config
|
||||||
|
currentNode.config = config;
|
||||||
|
currentNode.combinationStr = combinationStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traverse the tree with the current snapshot history
|
||||||
|
* @param {Object} treeRoot - Root of the tree
|
||||||
|
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
|
||||||
|
* @returns {Object|null} - Current node or null if no match
|
||||||
|
*/
|
||||||
|
function traverseTree(treeRoot, snapshotHistory) {
|
||||||
|
let currentNode = treeRoot;
|
||||||
|
|
||||||
|
for (const snapshot of snapshotHistory) {
|
||||||
|
const key = setToKey(snapshot);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're inside an input element where typing should work normally
|
||||||
|
* @returns {boolean} - True if inside an input-like element
|
||||||
|
*/
|
||||||
|
function isInInputContext() {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (!activeElement) return false;
|
||||||
|
|
||||||
|
const tagName = activeElement.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Check for input/textarea
|
||||||
|
if (tagName === 'input' || tagName === 'textarea') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contenteditable
|
||||||
|
if (activeElement.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an action for a matched combination
|
||||||
|
* @param {string} elementId - ID of the element
|
||||||
|
* @param {Object} config - HTMX configuration object
|
||||||
|
* @param {string} combinationStr - The matched combination string
|
||||||
|
* @param {boolean} isInside - Whether the focus is inside the element
|
||||||
|
*/
|
||||||
|
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const hasFocus = document.activeElement === element;
|
||||||
|
|
||||||
|
// Extract HTTP method and URL from hx-* attributes
|
||||||
|
let method = 'POST'; // default
|
||||||
|
let url = null;
|
||||||
|
|
||||||
|
const methodMap = {
|
||||||
|
'hx-post': 'POST',
|
||||||
|
'hx-get': 'GET',
|
||||||
|
'hx-put': 'PUT',
|
||||||
|
'hx-delete': 'DELETE',
|
||||||
|
'hx-patch': 'PATCH'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [attr, httpMethod] of Object.entries(methodMap)) {
|
||||||
|
if (config[attr]) {
|
||||||
|
method = httpMethod;
|
||||||
|
url = config[attr];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
console.error('No HTTP method attribute found in config:', config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build htmx.ajax options
|
||||||
|
const htmxOptions = {};
|
||||||
|
|
||||||
|
// Map hx-target to target
|
||||||
|
if (config['hx-target']) {
|
||||||
|
htmxOptions.target = config['hx-target'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-swap to swap
|
||||||
|
if (config['hx-swap']) {
|
||||||
|
htmxOptions.swap = config['hx-swap'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||||
|
const values = {};
|
||||||
|
if (config['hx-vals']) {
|
||||||
|
Object.assign(values, config['hx-vals']);
|
||||||
|
}
|
||||||
|
values.combination = combinationStr;
|
||||||
|
values.has_focus = hasFocus;
|
||||||
|
values.is_inside = isInside;
|
||||||
|
htmxOptions.values = values;
|
||||||
|
|
||||||
|
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
|
||||||
|
// Remove 'hx-' prefix and convert to camelCase
|
||||||
|
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
htmxOptions[optionKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make AJAX call with htmx
|
||||||
|
htmx.ajax(method, url, htmxOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyboard events and trigger matching combinations
|
||||||
|
* @param {KeyboardEvent} event - The keyboard event
|
||||||
|
*/
|
||||||
|
function handleKeyboardEvent(event) {
|
||||||
|
const key = normalizeKey(event.key);
|
||||||
|
|
||||||
|
// Add key to current pressed keys
|
||||||
|
KeyboardRegistry.currentKeys.add(key);
|
||||||
|
console.debug("Received key", key);
|
||||||
|
|
||||||
|
// Create a snapshot of current keyboard state
|
||||||
|
const snapshot = new Set(KeyboardRegistry.currentKeys);
|
||||||
|
|
||||||
|
// Add snapshot to history
|
||||||
|
KeyboardRegistry.snapshotHistory.push(snapshot);
|
||||||
|
|
||||||
|
// Cancel any pending timeout
|
||||||
|
if (KeyboardRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||||
|
KeyboardRegistry.pendingTimeout = null;
|
||||||
|
KeyboardRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect match information for all elements
|
||||||
|
const currentMatches = [];
|
||||||
|
let anyHasLongerSequence = false;
|
||||||
|
let foundAnyMatch = false;
|
||||||
|
|
||||||
|
// Check all registered elements for matching combinations
|
||||||
|
for (const [elementId, data] of KeyboardRegistry.elements) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) continue;
|
||||||
|
|
||||||
|
// Check if focus is inside this element (element itself or any child)
|
||||||
|
const isInside = element.contains(document.activeElement);
|
||||||
|
|
||||||
|
const treeRoot = data.tree;
|
||||||
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
// No match in this tree, continue to next element
|
||||||
|
console.debug("No match in tree for event", key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found at least a partial match
|
||||||
|
foundAnyMatch = true;
|
||||||
|
|
||||||
|
// Check if we have a match (node has a URL)
|
||||||
|
const hasMatch = currentNode.config !== null;
|
||||||
|
|
||||||
|
// Check if there are longer sequences possible (node has children)
|
||||||
|
const hasLongerSequences = currentNode.children.size > 0;
|
||||||
|
|
||||||
|
// Track if ANY element has longer sequences possible
|
||||||
|
if (hasLongerSequences) {
|
||||||
|
anyHasLongerSequence = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matches
|
||||||
|
if (hasMatch) {
|
||||||
|
currentMatches.push({
|
||||||
|
elementId: elementId,
|
||||||
|
config: currentNode.config,
|
||||||
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: isInside
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default if we found any match and not in input context
|
||||||
|
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic based on matches and longer sequences
|
||||||
|
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||||
|
// We have matches and NO element has longer sequences possible
|
||||||
|
// Trigger ALL matches immediately
|
||||||
|
for (const match of currentMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear history after triggering
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
|
||||||
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||||
|
// We have matches but AT LEAST ONE element has longer sequences possible
|
||||||
|
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||||
|
|
||||||
|
KeyboardRegistry.pendingMatches = currentMatches;
|
||||||
|
|
||||||
|
KeyboardRegistry.pendingTimeout = setTimeout(() => {
|
||||||
|
// Timeout expired, trigger ALL pending matches
|
||||||
|
for (const match of KeyboardRegistry.pendingMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
KeyboardRegistry.pendingMatches = [];
|
||||||
|
KeyboardRegistry.pendingTimeout = null;
|
||||||
|
}, KeyboardRegistry.sequenceTimeout);
|
||||||
|
|
||||||
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||||
|
// No matches yet but longer sequences are possible
|
||||||
|
// Just wait, don't trigger anything
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No matches and no longer sequences possible
|
||||||
|
// This is an invalid sequence - clear history
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no match at all, clear the history
|
||||||
|
// This handles invalid sequences like "A C" when only "A B" exists
|
||||||
|
if (!foundAnyMatch) {
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear history if it gets too long (prevent memory issues)
|
||||||
|
if (KeyboardRegistry.snapshotHistory.length > 10) {
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle keyup event to remove keys from current pressed keys
|
||||||
|
* @param {KeyboardEvent} event - The keyboard event
|
||||||
|
*/
|
||||||
|
function handleKeyUp(event) {
|
||||||
|
const key = normalizeKey(event.key);
|
||||||
|
KeyboardRegistry.currentKeys.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the global keyboard event listener if not already attached
|
||||||
|
*/
|
||||||
|
function attachGlobalListener() {
|
||||||
|
if (!KeyboardRegistry.listenerAttached) {
|
||||||
|
document.addEventListener('keydown', handleKeyboardEvent);
|
||||||
|
document.addEventListener('keyup', handleKeyUp);
|
||||||
|
KeyboardRegistry.listenerAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach the global keyboard event listener
|
||||||
|
*/
|
||||||
|
function detachGlobalListener() {
|
||||||
|
if (KeyboardRegistry.listenerAttached) {
|
||||||
|
document.removeEventListener('keydown', handleKeyboardEvent);
|
||||||
|
document.removeEventListener('keyup', handleKeyUp);
|
||||||
|
KeyboardRegistry.listenerAttached = false;
|
||||||
|
|
||||||
|
// Clean up all state
|
||||||
|
KeyboardRegistry.currentKeys.clear();
|
||||||
|
KeyboardRegistry.snapshotHistory = [];
|
||||||
|
if (KeyboardRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(KeyboardRegistry.pendingTimeout);
|
||||||
|
KeyboardRegistry.pendingTimeout = null;
|
||||||
|
}
|
||||||
|
KeyboardRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add keyboard support to an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||||
|
*/
|
||||||
|
window.add_keyboard_support = function (elementId, combinationsJson) {
|
||||||
|
// Parse the combinations JSON
|
||||||
|
const combinations = JSON.parse(combinationsJson);
|
||||||
|
|
||||||
|
// Build tree for this element
|
||||||
|
const tree = buildTree(combinations);
|
||||||
|
|
||||||
|
// Get element reference
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) {
|
||||||
|
console.error("Element with ID", elementId, "not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to registry
|
||||||
|
KeyboardRegistry.elements.set(elementId, {
|
||||||
|
tree: tree,
|
||||||
|
element: element
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach global listener if not already attached
|
||||||
|
attachGlobalListener();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove keyboard support from an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
*/
|
||||||
|
window.remove_keyboard_support = function (elementId) {
|
||||||
|
// Remove from registry
|
||||||
|
if (!KeyboardRegistry.elements.has(elementId)) {
|
||||||
|
console.warn("Element with ID", elementId, "not found in keyboard registry!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyboardRegistry.elements.delete(elementId);
|
||||||
|
|
||||||
|
// If no more elements, detach global listeners
|
||||||
|
if (KeyboardRegistry.elements.size === 0) {
|
||||||
|
detachGlobalListener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
634
tests/html/mouse_support.js
Normal file
634
tests/html/mouse_support.js
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
/**
|
||||||
|
* Create mouse bindings
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
/**
|
||||||
|
* Global registry to store mouse shortcuts for multiple elements
|
||||||
|
*/
|
||||||
|
const MouseRegistry = {
|
||||||
|
elements: new Map(), // elementId -> { tree, element }
|
||||||
|
listenerAttached: false,
|
||||||
|
snapshotHistory: [],
|
||||||
|
pendingTimeout: null,
|
||||||
|
pendingMatches: [], // Array of matches waiting for timeout
|
||||||
|
sequenceTimeout: 500, // 500ms timeout for sequences
|
||||||
|
clickHandler: null,
|
||||||
|
contextmenuHandler: null
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize mouse action names
|
||||||
|
* @param {string} action - The action to normalize
|
||||||
|
* @returns {string} - Normalized action name
|
||||||
|
*/
|
||||||
|
function normalizeAction(action) {
|
||||||
|
const normalized = action.toLowerCase().trim();
|
||||||
|
|
||||||
|
// Handle aliases
|
||||||
|
const aliasMap = {
|
||||||
|
'rclick': 'right_click'
|
||||||
|
};
|
||||||
|
|
||||||
|
return aliasMap[normalized] || normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a unique string key from a Set of actions for Map indexing
|
||||||
|
* @param {Set} actionSet - Set of normalized actions
|
||||||
|
* @returns {string} - Sorted string representation
|
||||||
|
*/
|
||||||
|
function setToKey(actionSet) {
|
||||||
|
return Array.from(actionSet).sort().join('+');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single element (can be a simple click or click with modifiers)
|
||||||
|
* @param {string} element - The element string (e.g., "click" or "ctrl+click")
|
||||||
|
* @returns {Set} - Set of normalized actions
|
||||||
|
*/
|
||||||
|
function parseElement(element) {
|
||||||
|
if (element.includes('+')) {
|
||||||
|
// Click with modifiers
|
||||||
|
return new Set(element.split('+').map(a => normalizeAction(a)));
|
||||||
|
}
|
||||||
|
// Simple click
|
||||||
|
return new Set([normalizeAction(element)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a combination string into sequence elements
|
||||||
|
* @param {string} combination - The combination string (e.g., "click right_click")
|
||||||
|
* @returns {Array} - Array of Sets representing the sequence
|
||||||
|
*/
|
||||||
|
function parseCombination(combination) {
|
||||||
|
// Check if it's a sequence (contains space)
|
||||||
|
if (combination.includes(' ')) {
|
||||||
|
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single element (can be a click or click with modifiers)
|
||||||
|
return [parseElement(combination)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new tree node
|
||||||
|
* @returns {Object} - New tree node
|
||||||
|
*/
|
||||||
|
function createTreeNode() {
|
||||||
|
return {
|
||||||
|
config: null,
|
||||||
|
combinationStr: null,
|
||||||
|
children: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a tree from combinations
|
||||||
|
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||||
|
* @returns {Object} - Root tree node
|
||||||
|
*/
|
||||||
|
function buildTree(combinations) {
|
||||||
|
const root = createTreeNode();
|
||||||
|
|
||||||
|
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||||
|
const sequence = parseCombination(combinationStr);
|
||||||
|
console.log("Parsing mouse combination", combinationStr, "=>", sequence);
|
||||||
|
let currentNode = root;
|
||||||
|
|
||||||
|
for (const actionSet of sequence) {
|
||||||
|
const key = setToKey(actionSet);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
currentNode.children.set(key, createTreeNode());
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as end of sequence and store config
|
||||||
|
currentNode.config = config;
|
||||||
|
currentNode.combinationStr = combinationStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traverse the tree with the current snapshot history
|
||||||
|
* @param {Object} treeRoot - Root of the tree
|
||||||
|
* @param {Array} snapshotHistory - Array of Sets representing mouse actions
|
||||||
|
* @returns {Object|null} - Current node or null if no match
|
||||||
|
*/
|
||||||
|
function traverseTree(treeRoot, snapshotHistory) {
|
||||||
|
let currentNode = treeRoot;
|
||||||
|
|
||||||
|
for (const snapshot of snapshotHistory) {
|
||||||
|
const key = setToKey(snapshot);
|
||||||
|
|
||||||
|
if (!currentNode.children.has(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNode = currentNode.children.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're inside an input element where clicking should work normally
|
||||||
|
* @returns {boolean} - True if inside an input-like element
|
||||||
|
*/
|
||||||
|
function isInInputContext() {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
if (!activeElement) return false;
|
||||||
|
|
||||||
|
const tagName = activeElement.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Check for input/textarea
|
||||||
|
if (tagName === 'input' || tagName === 'textarea') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for contenteditable
|
||||||
|
if (activeElement.isContentEditable) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the element that was actually clicked (from registered elements)
|
||||||
|
* @param {Element} target - The clicked element
|
||||||
|
* @returns {string|null} - Element ID if found, null otherwise
|
||||||
|
*/
|
||||||
|
function findRegisteredElement(target) {
|
||||||
|
// Check if target itself is registered
|
||||||
|
if (target.id && MouseRegistry.elements.has(target.id)) {
|
||||||
|
return target.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any parent is registered
|
||||||
|
let current = target.parentElement;
|
||||||
|
while (current) {
|
||||||
|
if (current.id && MouseRegistry.elements.has(current.id)) {
|
||||||
|
return current.id;
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a snapshot from mouse event
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||||
|
* @returns {Set} - Set of actions representing this click
|
||||||
|
*/
|
||||||
|
function createSnapshot(event, baseAction) {
|
||||||
|
const actions = new Set([baseAction]);
|
||||||
|
|
||||||
|
// Add modifiers if present
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
actions.add('ctrl');
|
||||||
|
}
|
||||||
|
if (event.shiftKey) {
|
||||||
|
actions.add('shift');
|
||||||
|
}
|
||||||
|
if (event.altKey) {
|
||||||
|
actions.add('alt');
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an action for a matched combination
|
||||||
|
* @param {string} elementId - ID of the element
|
||||||
|
* @param {Object} config - HTMX configuration object
|
||||||
|
* @param {string} combinationStr - The matched combination string
|
||||||
|
* @param {boolean} isInside - Whether the click was inside the element
|
||||||
|
*/
|
||||||
|
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const hasFocus = document.activeElement === element;
|
||||||
|
|
||||||
|
// Extract HTTP method and URL from hx-* attributes
|
||||||
|
let method = 'POST'; // default
|
||||||
|
let url = null;
|
||||||
|
|
||||||
|
const methodMap = {
|
||||||
|
'hx-post': 'POST',
|
||||||
|
'hx-get': 'GET',
|
||||||
|
'hx-put': 'PUT',
|
||||||
|
'hx-delete': 'DELETE',
|
||||||
|
'hx-patch': 'PATCH'
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [attr, httpMethod] of Object.entries(methodMap)) {
|
||||||
|
if (config[attr]) {
|
||||||
|
method = httpMethod;
|
||||||
|
url = config[attr];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
console.error('No HTTP method attribute found in config:', config);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build htmx.ajax options
|
||||||
|
const htmxOptions = {};
|
||||||
|
|
||||||
|
// Map hx-target to target
|
||||||
|
if (config['hx-target']) {
|
||||||
|
htmxOptions.target = config['hx-target'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-swap to swap
|
||||||
|
if (config['hx-swap']) {
|
||||||
|
htmxOptions.swap = config['hx-swap'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||||
|
const values = {};
|
||||||
|
if (config['hx-vals']) {
|
||||||
|
Object.assign(values, config['hx-vals']);
|
||||||
|
}
|
||||||
|
values.combination = combinationStr;
|
||||||
|
values.has_focus = hasFocus;
|
||||||
|
values.is_inside = isInside;
|
||||||
|
htmxOptions.values = values;
|
||||||
|
|
||||||
|
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
|
||||||
|
// Remove 'hx-' prefix and convert to camelCase
|
||||||
|
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
htmxOptions[optionKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make AJAX call with htmx
|
||||||
|
htmx.ajax(method, url, htmxOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle mouse events and trigger matching combinations
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||||
|
*/
|
||||||
|
function handleMouseEvent(event, baseAction) {
|
||||||
|
// Different behavior for click vs right_click
|
||||||
|
if (baseAction === 'click') {
|
||||||
|
// Click: trigger for ALL registered elements (useful for closing modals/popups)
|
||||||
|
handleGlobalClick(event);
|
||||||
|
} else if (baseAction === 'right_click') {
|
||||||
|
// Right-click: trigger ONLY if clicked on a registered element
|
||||||
|
handleElementRightClick(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle global click events (triggers for all registered elements)
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
*/
|
||||||
|
function handleGlobalClick(event) {
|
||||||
|
console.debug("Global click detected");
|
||||||
|
|
||||||
|
// Create a snapshot of current mouse action with modifiers
|
||||||
|
const snapshot = createSnapshot(event, 'click');
|
||||||
|
|
||||||
|
// Add snapshot to history
|
||||||
|
MouseRegistry.snapshotHistory.push(snapshot);
|
||||||
|
|
||||||
|
// Cancel any pending timeout
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect match information for ALL registered elements
|
||||||
|
const currentMatches = [];
|
||||||
|
let anyHasLongerSequence = false;
|
||||||
|
let foundAnyMatch = false;
|
||||||
|
|
||||||
|
for (const [elementId, data] of MouseRegistry.elements) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) continue;
|
||||||
|
|
||||||
|
// Check if click was inside this element
|
||||||
|
const isInside = element.contains(event.target);
|
||||||
|
|
||||||
|
const treeRoot = data.tree;
|
||||||
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
// No match in this tree
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found at least a partial match
|
||||||
|
foundAnyMatch = true;
|
||||||
|
|
||||||
|
// Check if we have a match (node has config)
|
||||||
|
const hasMatch = currentNode.config !== null;
|
||||||
|
|
||||||
|
// Check if there are longer sequences possible (node has children)
|
||||||
|
const hasLongerSequences = currentNode.children.size > 0;
|
||||||
|
|
||||||
|
if (hasLongerSequences) {
|
||||||
|
anyHasLongerSequence = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matches
|
||||||
|
if (hasMatch) {
|
||||||
|
currentMatches.push({
|
||||||
|
elementId: elementId,
|
||||||
|
config: currentNode.config,
|
||||||
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: isInside
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default if we found any match and not in input context
|
||||||
|
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic based on matches and longer sequences
|
||||||
|
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||||
|
// We have matches and NO longer sequences possible
|
||||||
|
// Trigger ALL matches immediately
|
||||||
|
for (const match of currentMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear history after triggering
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
|
||||||
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||||
|
// We have matches but longer sequences are possible
|
||||||
|
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||||
|
|
||||||
|
MouseRegistry.pendingMatches = currentMatches;
|
||||||
|
|
||||||
|
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||||
|
// Timeout expired, trigger ALL pending matches
|
||||||
|
for (const match of MouseRegistry.pendingMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}, MouseRegistry.sequenceTimeout);
|
||||||
|
|
||||||
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||||
|
// No matches yet but longer sequences are possible
|
||||||
|
// Just wait, don't trigger anything
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No matches and no longer sequences possible
|
||||||
|
// This is an invalid sequence - clear history
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no match at all, clear the history
|
||||||
|
if (!foundAnyMatch) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear history if it gets too long (prevent memory issues)
|
||||||
|
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle right-click events (triggers only for clicked element)
|
||||||
|
* @param {MouseEvent} event - The mouse event
|
||||||
|
*/
|
||||||
|
function handleElementRightClick(event) {
|
||||||
|
// Find which registered element was clicked
|
||||||
|
const elementId = findRegisteredElement(event.target);
|
||||||
|
|
||||||
|
if (!elementId) {
|
||||||
|
// Right-click wasn't on a registered element - don't prevent default
|
||||||
|
// This allows browser context menu to appear
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug("Right-click on registered element", elementId);
|
||||||
|
|
||||||
|
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
|
||||||
|
const clickedInside = true;
|
||||||
|
|
||||||
|
// Create a snapshot of current mouse action with modifiers
|
||||||
|
const snapshot = createSnapshot(event, 'right_click');
|
||||||
|
|
||||||
|
// Add snapshot to history
|
||||||
|
MouseRegistry.snapshotHistory.push(snapshot);
|
||||||
|
|
||||||
|
// Cancel any pending timeout
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect match information for this element
|
||||||
|
const currentMatches = [];
|
||||||
|
let anyHasLongerSequence = false;
|
||||||
|
let foundAnyMatch = false;
|
||||||
|
|
||||||
|
const data = MouseRegistry.elements.get(elementId);
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const treeRoot = data.tree;
|
||||||
|
|
||||||
|
// Traverse the tree with current snapshot history
|
||||||
|
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
// No match in this tree
|
||||||
|
console.debug("No match in tree for right-click");
|
||||||
|
// Clear history for invalid sequences
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We found at least a partial match
|
||||||
|
foundAnyMatch = true;
|
||||||
|
|
||||||
|
// Check if we have a match (node has config)
|
||||||
|
const hasMatch = currentNode.config !== null;
|
||||||
|
|
||||||
|
// Check if there are longer sequences possible (node has children)
|
||||||
|
const hasLongerSequences = currentNode.children.size > 0;
|
||||||
|
|
||||||
|
if (hasLongerSequences) {
|
||||||
|
anyHasLongerSequence = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect matches
|
||||||
|
if (hasMatch) {
|
||||||
|
currentMatches.push({
|
||||||
|
elementId: elementId,
|
||||||
|
config: currentNode.config,
|
||||||
|
combinationStr: currentNode.combinationStr,
|
||||||
|
isInside: true // Right-click only triggers when clicking on element
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent default if we found any match and not in input context
|
||||||
|
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision logic based on matches and longer sequences
|
||||||
|
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||||
|
// We have matches and NO longer sequences possible
|
||||||
|
// Trigger ALL matches immediately
|
||||||
|
for (const match of currentMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear history after triggering
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
|
||||||
|
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||||
|
// We have matches but longer sequences are possible
|
||||||
|
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||||
|
|
||||||
|
MouseRegistry.pendingMatches = currentMatches;
|
||||||
|
|
||||||
|
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||||
|
// Timeout expired, trigger ALL pending matches
|
||||||
|
for (const match of MouseRegistry.pendingMatches) {
|
||||||
|
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}, MouseRegistry.sequenceTimeout);
|
||||||
|
|
||||||
|
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||||
|
// No matches yet but longer sequences are possible
|
||||||
|
// Just wait, don't trigger anything
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No matches and no longer sequences possible
|
||||||
|
// This is an invalid sequence - clear history
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found no match at all, clear the history
|
||||||
|
if (!foundAnyMatch) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also clear history if it gets too long (prevent memory issues)
|
||||||
|
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the global mouse event listeners if not already attached
|
||||||
|
*/
|
||||||
|
function attachGlobalListener() {
|
||||||
|
if (!MouseRegistry.listenerAttached) {
|
||||||
|
// Store handler references for proper removal
|
||||||
|
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
|
||||||
|
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
|
||||||
|
|
||||||
|
document.addEventListener('click', MouseRegistry.clickHandler);
|
||||||
|
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||||
|
MouseRegistry.listenerAttached = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detach the global mouse event listeners
|
||||||
|
*/
|
||||||
|
function detachGlobalListener() {
|
||||||
|
if (MouseRegistry.listenerAttached) {
|
||||||
|
document.removeEventListener('click', MouseRegistry.clickHandler);
|
||||||
|
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||||
|
MouseRegistry.listenerAttached = false;
|
||||||
|
|
||||||
|
// Clean up handler references
|
||||||
|
MouseRegistry.clickHandler = null;
|
||||||
|
MouseRegistry.contextmenuHandler = null;
|
||||||
|
|
||||||
|
// Clean up all state
|
||||||
|
MouseRegistry.snapshotHistory = [];
|
||||||
|
if (MouseRegistry.pendingTimeout) {
|
||||||
|
clearTimeout(MouseRegistry.pendingTimeout);
|
||||||
|
MouseRegistry.pendingTimeout = null;
|
||||||
|
}
|
||||||
|
MouseRegistry.pendingMatches = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add mouse support to an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||||
|
*/
|
||||||
|
window.add_mouse_support = function (elementId, combinationsJson) {
|
||||||
|
// Parse the combinations JSON
|
||||||
|
const combinations = JSON.parse(combinationsJson);
|
||||||
|
|
||||||
|
// Build tree for this element
|
||||||
|
const tree = buildTree(combinations);
|
||||||
|
|
||||||
|
// Get element reference
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
if (!element) {
|
||||||
|
console.error("Element with ID", elementId, "not found!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to registry
|
||||||
|
MouseRegistry.elements.set(elementId, {
|
||||||
|
tree: tree,
|
||||||
|
element: element
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach global listener if not already attached
|
||||||
|
attachGlobalListener();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove mouse support from an element
|
||||||
|
* @param {string} elementId - The ID of the element
|
||||||
|
*/
|
||||||
|
window.remove_mouse_support = function (elementId) {
|
||||||
|
// Remove from registry
|
||||||
|
if (!MouseRegistry.elements.has(elementId)) {
|
||||||
|
console.warn("Element with ID", elementId, "not found in mouse registry!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseRegistry.elements.delete(elementId);
|
||||||
|
|
||||||
|
// If no more elements, detach global listeners
|
||||||
|
if (MouseRegistry.elements.size === 0) {
|
||||||
|
detachGlobalListener();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -137,6 +137,12 @@
|
|||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
<h2>Test Input (typing should work normally here)</h2>
|
<h2>Test Input (typing should work normally here)</h2>
|
||||||
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
|
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
|
||||||
|
<p style="margin-top: 10px; padding: 10px; background-color: #e3f2fd; border-left: 4px solid #2196F3; border-radius: 3px;">
|
||||||
|
<strong>Parameters Explained:</strong><br>
|
||||||
|
• <code>has_focus</code>: Whether the registered element itself has focus<br>
|
||||||
|
• <code>is_inside</code>: Whether the focus is on the registered element or any of its children<br>
|
||||||
|
<em>Example: If focus is on this input and its parent div is registered, has_focus=false but is_inside=true</em>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
@@ -151,6 +157,9 @@
|
|||||||
<div id="test-element-2" class="test-element" tabindex="0">
|
<div id="test-element-2" class="test-element" tabindex="0">
|
||||||
This element also responds to ESC and Shift Shift
|
This element also responds to ESC and Shift Shift
|
||||||
</div>
|
</div>
|
||||||
|
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
|
||||||
|
Remove Element 2 Keyboard Support
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="test-container">
|
<div class="test-container">
|
||||||
@@ -172,12 +181,14 @@
|
|||||||
window.htmx.ajax = function(method, url, config) {
|
window.htmx.ajax = function(method, url, config) {
|
||||||
const timestamp = new Date().toLocaleTimeString();
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
const hasFocus = config.values.has_focus;
|
const hasFocus = config.values.has_focus;
|
||||||
|
const isInside = config.values.is_inside;
|
||||||
const combination = config.values.combination;
|
const combination = config.values.combination;
|
||||||
|
|
||||||
// Build details string with all config options
|
// Build details string with all config options
|
||||||
const details = [
|
const details = [
|
||||||
`Combination: "${combination}"`,
|
`Combination: "${combination}"`,
|
||||||
`Element has focus: ${hasFocus}`
|
`Element has focus: ${hasFocus}`,
|
||||||
|
`Focus inside element: ${isInside}`
|
||||||
];
|
];
|
||||||
|
|
||||||
if (config.target) {
|
if (config.target) {
|
||||||
@@ -223,6 +234,16 @@
|
|||||||
function clearLog() {
|
function clearLog() {
|
||||||
document.getElementById('log').innerHTML = '';
|
document.getElementById('log').innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeElement2() {
|
||||||
|
remove_keyboard_support('test-element-2');
|
||||||
|
logEvent('Element 2 keyboard support removed',
|
||||||
|
'ESC and Shift Shift no longer trigger for Element 2',
|
||||||
|
'Element 1 still active', false);
|
||||||
|
// Disable the button
|
||||||
|
event.target.disabled = true;
|
||||||
|
event.target.textContent = 'Keyboard Support Removed';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Include keyboard support script -->
|
<!-- Include keyboard support script -->
|
||||||
356
tests/html/test_mouse_support.html
Normal file
356
tests/html/test_mouse_support.html
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mouse Support Test</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-container {
|
||||||
|
border: 2px solid #333;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-element {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: 2px solid #999;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 10px 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-element:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-element:focus {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-color: #2196F3;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-container {
|
||||||
|
background-color: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry {
|
||||||
|
margin: 5px 0;
|
||||||
|
padding: 5px;
|
||||||
|
border-left: 3px solid #4CAF50;
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.focus {
|
||||||
|
border-left-color: #2196F3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-entry.no-focus {
|
||||||
|
border-left-color: #FF9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffc107;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list ul {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-list code {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button {
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-button:hover {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
background-color: #FF5722;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button:hover {
|
||||||
|
background-color: #E64A19;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-left: 4px solid #2196F3;
|
||||||
|
padding: 10px 15px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Mouse Support Test Page</h1>
|
||||||
|
|
||||||
|
<div class="actions-list">
|
||||||
|
<h3>🖱️ Configured Mouse Actions</h3>
|
||||||
|
<p><strong>Element 1 - All Actions:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>click</code> - Simple left click</li>
|
||||||
|
<li><code>right_click</code> - Right click (context menu blocked)</li>
|
||||||
|
<li><code>ctrl+click</code> - Ctrl/Cmd + Click</li>
|
||||||
|
<li><code>shift+click</code> - Shift + Click</li>
|
||||||
|
<li><code>ctrl+shift+click</code> - Ctrl + Shift + Click</li>
|
||||||
|
<li><code>click right_click</code> - Click then right-click within 500ms</li>
|
||||||
|
<li><code>click click</code> - Click twice in sequence</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Element 2 - Using rclick alias:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>click</code> - Simple click</li>
|
||||||
|
<li><code>rclick</code> - Right click (using rclick alias)</li>
|
||||||
|
<li><code>click rclick</code> - Click then right-click sequence (using alias)</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Note:</strong> <code>rclick</code> is an alias for <code>right_click</code> and works identically.</p>
|
||||||
|
<p><strong>Tip:</strong> Try different click combinations! Right-click menu will be blocked on test elements.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Click Behavior:</strong> The <code>click</code> action is detected GLOBALLY (anywhere on the page).
|
||||||
|
Try clicking outside the test elements - the click action will still trigger! The <code>is_inside</code>
|
||||||
|
parameter tells you if the click was inside or outside the element (perfect for "close popup if clicked outside" logic).
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Right-Click Behavior:</strong> The <code>right_click</code> action is detected ONLY when clicking ON the element.
|
||||||
|
Try right-clicking outside the test elements - the browser's context menu will appear normally.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="note">
|
||||||
|
<strong>Mac Users:</strong> Use Cmd (⌘) instead of Ctrl. The library handles cross-platform compatibility automatically.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Element 1 (All Actions)</h2>
|
||||||
|
<div id="test-element-1" class="test-element" tabindex="0">
|
||||||
|
Try different mouse actions here!<br>
|
||||||
|
Click, Right-click, Ctrl+Click, Shift+Click, sequences...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Element 2 (Using rclick alias)</h2>
|
||||||
|
<div id="test-element-2" class="test-element" tabindex="0">
|
||||||
|
This element uses "rclick" alias for right-click<br>
|
||||||
|
Also has a "click rclick" sequence
|
||||||
|
</div>
|
||||||
|
<button class="remove-button" onclick="removeElement2()">
|
||||||
|
Remove Element 2 Mouse Support
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Test Input (normal clicking should work here)</h2>
|
||||||
|
<input type="text" placeholder="Try clicking, right-clicking here - should work normally"
|
||||||
|
style="width: 100%; padding: 10px; font-size: 14px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container" style="background-color: #f9f9f9;">
|
||||||
|
<h2>🎯 Click Outside Test Area</h2>
|
||||||
|
<p>Click anywhere in this gray area (outside the test elements above) to see that <code>click</code> is detected globally!</p>
|
||||||
|
<p style="margin-top: 20px; padding: 30px; background-color: white; border: 2px dashed #999; border-radius: 5px; text-align: center;">
|
||||||
|
This is just empty space - but clicking here will still trigger the registered <code>click</code> actions!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>Event Log</h2>
|
||||||
|
<button class="clear-button" onclick="clearLog()">Clear Log</button>
|
||||||
|
<div id="log" class="log-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include htmx -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
|
||||||
|
<!-- Mock htmx.ajax for testing -->
|
||||||
|
<script>
|
||||||
|
// Store original htmx.ajax if it exists
|
||||||
|
const originalHtmxAjax = window.htmx && window.htmx.ajax;
|
||||||
|
|
||||||
|
// Override htmx.ajax for testing purposes
|
||||||
|
if (window.htmx) {
|
||||||
|
window.htmx.ajax = function(method, url, config) {
|
||||||
|
const timestamp = new Date().toLocaleTimeString();
|
||||||
|
const hasFocus = config.values.has_focus;
|
||||||
|
const isInside = config.values.is_inside;
|
||||||
|
const combination = config.values.combination;
|
||||||
|
|
||||||
|
// Build details string with all config options
|
||||||
|
const details = [
|
||||||
|
`Combination: "${combination}"`,
|
||||||
|
`Element has focus: ${hasFocus}`,
|
||||||
|
`Click inside element: ${isInside}`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (config.target) {
|
||||||
|
details.push(`Target: ${config.target}`);
|
||||||
|
}
|
||||||
|
if (config.swap) {
|
||||||
|
details.push(`Swap: ${config.swap}`);
|
||||||
|
}
|
||||||
|
if (config.values) {
|
||||||
|
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus' && k !== 'is_inside');
|
||||||
|
if (extraVals.length > 0) {
|
||||||
|
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logEvent(
|
||||||
|
`[${timestamp}] ${method} ${url}`,
|
||||||
|
...details,
|
||||||
|
hasFocus
|
||||||
|
);
|
||||||
|
|
||||||
|
// Uncomment below to use real htmx.ajax if you have a backend
|
||||||
|
// if (originalHtmxAjax) {
|
||||||
|
// originalHtmxAjax.call(this, method, url, config);
|
||||||
|
// }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logEvent(title, ...details) {
|
||||||
|
const log = document.getElementById('log');
|
||||||
|
const hasFocus = details[details.length - 1];
|
||||||
|
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
|
||||||
|
entry.innerHTML = `
|
||||||
|
<strong>${title}</strong><br>
|
||||||
|
${details.slice(0, -1).join('<br>')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
log.insertBefore(entry, log.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLog() {
|
||||||
|
document.getElementById('log').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeElement2() {
|
||||||
|
remove_mouse_support('test-element-2');
|
||||||
|
logEvent('Element 2 mouse support removed',
|
||||||
|
'Click and right-click no longer trigger for Element 2',
|
||||||
|
'Element 1 still active', false);
|
||||||
|
// Disable the button
|
||||||
|
event.target.disabled = true;
|
||||||
|
event.target.textContent = 'Mouse Support Removed';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Include mouse support script -->
|
||||||
|
<script src="mouse_support.js"></script>
|
||||||
|
|
||||||
|
<!-- Initialize mouse support -->
|
||||||
|
<script>
|
||||||
|
// Element 1 - Full configuration
|
||||||
|
const combinations1 = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/test/click"
|
||||||
|
},
|
||||||
|
"right_click": {
|
||||||
|
"hx-post": "/test/right-click"
|
||||||
|
},
|
||||||
|
"ctrl+click": {
|
||||||
|
"hx-post": "/test/ctrl-click",
|
||||||
|
"hx-swap": "innerHTML"
|
||||||
|
},
|
||||||
|
"shift+click": {
|
||||||
|
"hx-post": "/test/shift-click",
|
||||||
|
"hx-target": "#result"
|
||||||
|
},
|
||||||
|
"ctrl+shift+click": {
|
||||||
|
"hx-post": "/test/ctrl-shift-click",
|
||||||
|
"hx-vals": {"modifier": "both"}
|
||||||
|
},
|
||||||
|
"click right_click": {
|
||||||
|
"hx-post": "/test/click-then-right-click",
|
||||||
|
"hx-vals": {"type": "sequence"}
|
||||||
|
},
|
||||||
|
"click click": {
|
||||||
|
"hx-post": "/test/double-click-sequence"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_mouse_support('test-element-1', JSON.stringify(combinations1));
|
||||||
|
|
||||||
|
// Element 2 - Using rclick alias
|
||||||
|
const combinations2 = {
|
||||||
|
"click": {
|
||||||
|
"hx-post": "/test/element2-click"
|
||||||
|
},
|
||||||
|
"rclick": { // Using rclick alias instead of right_click
|
||||||
|
"hx-post": "/test/element2-rclick"
|
||||||
|
},
|
||||||
|
"click rclick": { // Sequence using rclick alias
|
||||||
|
"hx-post": "/test/element2-click-rclick-sequence"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
add_mouse_support('test-element-2', JSON.stringify(combinations2));
|
||||||
|
|
||||||
|
// Log initial state
|
||||||
|
logEvent('Mouse support initialized',
|
||||||
|
'Element 1: All mouse actions configured',
|
||||||
|
'Element 2: Using "rclick" alias (click, rclick, and click rclick sequence)',
|
||||||
|
'Smart timeout: 500ms for sequences', false);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from fasthtml.components import Div, Span
|
from fastcore.basics import NotStr
|
||||||
|
from fasthtml.components import Div, Span, Main
|
||||||
|
|
||||||
from myfasthtml.test.matcher import find
|
from myfasthtml.test.matcher import find, TestObject, Contains, StartsWith
|
||||||
|
|
||||||
|
|
||||||
|
class Dummy:
|
||||||
|
def __init__(self, attr1, attr2=None):
|
||||||
|
self.attr1 = attr1
|
||||||
|
self.attr2 = attr2
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (isinstance(other, Dummy)
|
||||||
|
and self.attr1 == other.attr1
|
||||||
|
and self.attr2 == other.attr2)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash((self.attr1, self.attr2))
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('ft, expected', [
|
@pytest.mark.parametrize('ft, expected', [
|
||||||
@@ -9,10 +24,13 @@ from myfasthtml.test.matcher import find
|
|||||||
(Div(id="id1"), Div(id="id1")),
|
(Div(id="id1"), Div(id="id1")),
|
||||||
(Div(Span(id="span_id"), id="div_id1"), Div(Span(id="span_id"), id="div_id1")),
|
(Div(Span(id="span_id"), id="div_id1"), Div(Span(id="span_id"), id="div_id1")),
|
||||||
(Div(id="id1", id2="id2"), Div(id="id1")),
|
(Div(id="id1", id2="id2"), Div(id="id1")),
|
||||||
(Div(Div(id="id2"), id2="id1"), Div(id="id1")),
|
(Div(Div(id="id2"), id="id1"), Div(id="id1")),
|
||||||
|
(Dummy(attr1="value"), Dummy(attr1="value")),
|
||||||
|
(Dummy(attr1="value"), TestObject(Dummy, attr1="value")),
|
||||||
|
(Div(attr="value1 value2"), Div(attr=Contains("value1"))),
|
||||||
])
|
])
|
||||||
def test_i_can_find(ft, expected):
|
def test_i_can_find(ft, expected):
|
||||||
assert find(expected, expected) == [expected]
|
assert find(ft, expected) == [ft]
|
||||||
|
|
||||||
|
|
||||||
def test_find_element_by_id_in_a_list():
|
def test_find_element_by_id_in_a_list():
|
||||||
@@ -25,12 +43,41 @@ def test_find_element_by_id_in_a_list():
|
|||||||
|
|
||||||
def test_i_can_find_sub_element():
|
def test_i_can_find_sub_element():
|
||||||
a = Div(id="id1")
|
a = Div(id="id1")
|
||||||
b = Div(a, id="id2")
|
b = Span(a, id="id2")
|
||||||
c = Div(b, id="id3")
|
c = Main(b, id="id3")
|
||||||
|
|
||||||
assert find(c, a) == [a]
|
assert find(c, a) == [a]
|
||||||
|
|
||||||
|
|
||||||
|
def test_i_can_find_when_pattern_appears_also_in_children():
|
||||||
|
a1 = Div(id="id1")
|
||||||
|
b = Div(a1, id="id2")
|
||||||
|
a2 = Div(b, id="id1")
|
||||||
|
c = Main(a2, id="id3")
|
||||||
|
|
||||||
|
assert find(c, a1) == [a2, a1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('ft, to_search, expected', [
|
||||||
|
(NotStr("hello"), NotStr("hello"), [NotStr("hello")]),
|
||||||
|
(NotStr("hello my friend"), NotStr("hello"), NotStr("hello my friend")),
|
||||||
|
(NotStr("hello"), TestObject(NotStr, s="hello"), [NotStr("hello")]),
|
||||||
|
(NotStr("hello my friend"), TestObject(NotStr, s=StartsWith("hello")), NotStr("hello my friend")),
|
||||||
|
])
|
||||||
|
def test_i_can_manage_notstr_success_path(ft, to_search, expected):
|
||||||
|
assert find(ft, to_search) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('ft, to_search', [
|
||||||
|
(NotStr("my friend"), NotStr("hello")),
|
||||||
|
(NotStr("hello"), Dummy(attr1="hello")), # important, because of the internal __eq__ of NotStr
|
||||||
|
(NotStr("hello my friend"), TestObject(NotStr, s="hello")),
|
||||||
|
])
|
||||||
|
def test_test_i_can_manage_notstr_failure_path(ft, to_search):
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
find(ft, to_search)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('ft, expected', [
|
@pytest.mark.parametrize('ft, expected', [
|
||||||
(None, Div(id="id1")),
|
(None, Div(id="id1")),
|
||||||
(Span(id="id1"), Div(id="id1")),
|
(Span(id="id1"), Div(id="id1")),
|
||||||
|
|||||||
@@ -2,16 +2,35 @@ import pytest
|
|||||||
from fastcore.basics import NotStr
|
from fastcore.basics import NotStr
|
||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
|
from myfasthtml.controls.helpers import mk
|
||||||
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren
|
from myfasthtml.core.commands import Command
|
||||||
|
from myfasthtml.icons.fluent_p3 import add20_regular
|
||||||
|
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, ErrorOutput, \
|
||||||
|
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject, Skip, DoNotCheck, TestIcon, HasHtmx
|
||||||
from myfasthtml.test.testclient import MyFT
|
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', [
|
@pytest.mark.parametrize('actual, expected', [
|
||||||
(None, None),
|
(None, None),
|
||||||
(123, 123),
|
(123, 123),
|
||||||
(Div(), Div()),
|
(Div(), Div()),
|
||||||
([Div(), Span()], [Div(), Span()]),
|
([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"), Div(attr1="value")),
|
||||||
(Div(attr1="value", attr2="value"), Div(attr1="value")),
|
(Div(attr1="value", attr2="value"), Div(attr1="value")),
|
||||||
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
|
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
|
||||||
@@ -30,11 +49,20 @@ from myfasthtml.test.testclient import MyFT
|
|||||||
(Div(123), Div(123)),
|
(Div(123), Div(123)),
|
||||||
(Div(Span(123)), Div(Span(123))),
|
(Div(Span(123)), Div(Span(123))),
|
||||||
(Div(Span(123)), Div(DoNotCheck())),
|
(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")),
|
||||||
|
(mk.icon(add20_regular), TestIcon("Add20Regular")),
|
||||||
|
(mk.icon(add20_regular), TestIcon("add20_regular")),
|
||||||
|
(mk.icon(add20_regular), TestIcon()),
|
||||||
|
(Div(None, None, None, Div(id="to_find")), Div(Skip(None), Div(id="to_find"))),
|
||||||
|
(Div(Div(id="to_skip"), Div(id="to_skip"), Div(id="to_find")), Div(Skip(Div(id="to_skip")), Div(id="to_find"))),
|
||||||
|
(Div(hx_post="/url"), Div(HasHtmx(hx_post="/url"))),
|
||||||
])
|
])
|
||||||
def test_i_can_match(actual, expected):
|
def test_i_can_match(self, actual, expected):
|
||||||
assert matches(actual, expected)
|
assert matches(actual, expected)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('actual, expected, error_message', [
|
@pytest.mark.parametrize('actual, expected, error_message', [
|
||||||
(None, Div(), "Actual is None"),
|
(None, Div(), "Actual is None"),
|
||||||
(Div(), None, "Actual is not None"),
|
(Div(), None, "Actual is not None"),
|
||||||
@@ -44,8 +72,8 @@ def test_i_can_match(actual, expected):
|
|||||||
([], [Div(), Span()], "Actual is smaller than expected"),
|
([], [Div(), Span()], "Actual is smaller than expected"),
|
||||||
("not a list", [Div(), Span()], "The types are different"),
|
("not a list", [Div(), Span()], "The types are different"),
|
||||||
([Div(), Span()], [Div(), 123], "The types are different"),
|
([Div(), Span()], [Div(), 123], "The types are different"),
|
||||||
(Div(), Span(), "The elements are different"),
|
(Div(), Span(), "The types are different"),
|
||||||
([Div(), Span()], [Div(), Div()], "The elements are different"),
|
([Div(), Span()], [Div(), Div()], "The types are different"),
|
||||||
(Div(), Div(attr1="value"), "'attr1' is not found in Actual"),
|
(Div(), Div(attr1="value"), "'attr1' is not found in Actual"),
|
||||||
(Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"),
|
(Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"),
|
||||||
(Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"),
|
(Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"),
|
||||||
@@ -61,19 +89,29 @@ def test_i_can_match(actual, expected):
|
|||||||
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
|
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
|
||||||
(Div(), Div(Span()), "Actual is lesser than expected"),
|
(Div(), Div(Span()), "Actual is lesser than expected"),
|
||||||
(Div(), Div(123), "Actual is lesser than expected"),
|
(Div(), Div(123), "Actual is lesser than expected"),
|
||||||
(Div(Span()), Div(Div()), "The elements are different"),
|
(Div(Span()), Div(Div()), "The types are different"),
|
||||||
(Div(123), Div(Div()), "The types are different"),
|
(Div(123), Div(Div()), "The types are different"),
|
||||||
(Div(123), Div(456), "The values are different"),
|
(Div(123), Div(456), "The values are different"),
|
||||||
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
|
(Div(Span(), Span()), Div(Span(), Div()), "The types are different"),
|
||||||
(Div(Span(Div())), Div(Span(Span())), "The elements are different"),
|
(Div(Span(Div())), Div(Span(Span())), "The types are different"),
|
||||||
(Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
|
(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"),
|
||||||
|
(Div(Div(id="to_skip")), Div(Skip(Div(id="to_skip"))), "Nothing more to skip"),
|
||||||
|
(Div(hx_post="/url"), Div(HasHtmx(hx_post="/url2")), "The condition 'HasHtmx()' 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:
|
with pytest.raises(AssertionError) as exc_info:
|
||||||
matches(actual, expected)
|
matches(actual, expected)
|
||||||
assert error_message in str(exc_info.value)
|
assert error_message in str(exc_info.value)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('element, expected_path', [
|
@pytest.mark.parametrize('element, expected_path', [
|
||||||
(Div(), "Path : 'div"),
|
(Div(), "Path : 'div"),
|
||||||
(Div(Span()), "Path : 'div.span"),
|
(Div(Span()), "Path : 'div.span"),
|
||||||
@@ -84,7 +122,7 @@ def test_i_can_detect_errors(actual, expected, error_message):
|
|||||||
(Div(attr="value"), "Path : 'div"),
|
(Div(attr="value"), "Path : 'div"),
|
||||||
(Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].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):
|
def _construct_test_element(source, tail):
|
||||||
res = MyFT(source.tag, source.attrs)
|
res = MyFT(source.tag, source.attrs)
|
||||||
if source.children:
|
if source.children:
|
||||||
@@ -101,7 +139,9 @@ def test_i_can_properly_show_path(element, expected_path):
|
|||||||
assert expected_path in str(exc_info.value)
|
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()
|
elt = Div()
|
||||||
expected = Div()
|
expected = Div()
|
||||||
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
|
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
|
||||||
@@ -113,8 +153,7 @@ def test_i_can_output_error_path():
|
|||||||
' (p "name"="p_name" ...',
|
' (p "name"="p_name" ...',
|
||||||
' (div )']
|
' (div )']
|
||||||
|
|
||||||
|
def test_i_can_output_error_attribute(self):
|
||||||
def test_i_can_output_error_attribute():
|
|
||||||
elt = Div(attr1="value1", attr2="value2")
|
elt = Div(attr1="value1", attr2="value2")
|
||||||
expected = elt
|
expected = elt
|
||||||
path = ""
|
path = ""
|
||||||
@@ -122,8 +161,7 @@ def test_i_can_output_error_attribute():
|
|||||||
error_output.compute()
|
error_output.compute()
|
||||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
|
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
|
||||||
|
|
||||||
|
def test_i_can_output_error_attribute_missing_1(self):
|
||||||
def test_i_can_output_error_attribute_missing_1():
|
|
||||||
elt = Div(attr2="value2")
|
elt = Div(attr2="value2")
|
||||||
expected = Div(attr1="value1", attr2="value2")
|
expected = Div(attr1="value1", attr2="value2")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -132,8 +170,7 @@ def test_i_can_output_error_attribute_missing_1():
|
|||||||
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
|
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
|
||||||
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
|
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
|
||||||
|
|
||||||
|
def test_i_can_output_error_attribute_missing_2(self):
|
||||||
def test_i_can_output_error_attribute_missing_2():
|
|
||||||
elt = Div(attr1="value1")
|
elt = Div(attr1="value1")
|
||||||
expected = Div(attr1="value1", attr2="value2")
|
expected = Div(attr1="value1", attr2="value2")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -142,8 +179,7 @@ def test_i_can_output_error_attribute_missing_2():
|
|||||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
|
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
|
||||||
' ^^^^^^^^^^^^^^^^^^^^^^^']
|
' ^^^^^^^^^^^^^^^^^^^^^^^']
|
||||||
|
|
||||||
|
def test_i_can_output_error_attribute_wrong_value(self):
|
||||||
def test_i_can_output_error_attribute_wrong_value():
|
|
||||||
elt = Div(attr1="value3", attr2="value2")
|
elt = Div(attr1="value3", attr2="value2")
|
||||||
expected = Div(attr1="value1", attr2="value2")
|
expected = Div(attr1="value1", attr2="value2")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -152,8 +188,7 @@ def test_i_can_output_error_attribute_wrong_value():
|
|||||||
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
|
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
|
||||||
' ^^^^^^^^^^^^^^^^ ']
|
' ^^^^^^^^^^^^^^^^ ']
|
||||||
|
|
||||||
|
def test_i_can_output_error_constant(self):
|
||||||
def test_i_can_output_error_constant():
|
|
||||||
elt = 123
|
elt = 123
|
||||||
expected = elt
|
expected = elt
|
||||||
path = ""
|
path = ""
|
||||||
@@ -161,8 +196,7 @@ def test_i_can_output_error_constant():
|
|||||||
error_output.compute()
|
error_output.compute()
|
||||||
assert error_output.output == ['123']
|
assert error_output.output == ['123']
|
||||||
|
|
||||||
|
def test_i_can_output_error_constant_wrong_value(self):
|
||||||
def test_i_can_output_error_constant_wrong_value():
|
|
||||||
elt = 123
|
elt = 123
|
||||||
expected = 456
|
expected = 456
|
||||||
path = ""
|
path = ""
|
||||||
@@ -171,8 +205,7 @@ def test_i_can_output_error_constant_wrong_value():
|
|||||||
assert error_output.output == ['123',
|
assert error_output.output == ['123',
|
||||||
'^^^']
|
'^^^']
|
||||||
|
|
||||||
|
def test_i_can_output_error_when_predicate(self):
|
||||||
def test_i_can_output_error_when_predicate():
|
|
||||||
elt = "before value after"
|
elt = "before value after"
|
||||||
expected = Contains("value")
|
expected = Contains("value")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -180,8 +213,7 @@ def test_i_can_output_error_when_predicate():
|
|||||||
error_output.compute()
|
error_output.compute()
|
||||||
assert error_output.output == ["before value after"]
|
assert error_output.output == ["before value after"]
|
||||||
|
|
||||||
|
def test_i_can_output_error_when_predicate_wrong_value(self):
|
||||||
def test_i_can_output_error_when_predicate_wrong_value():
|
|
||||||
"""I can display error when the condition predicate is not satisfied."""
|
"""I can display error when the condition predicate is not satisfied."""
|
||||||
elt = "before after"
|
elt = "before after"
|
||||||
expected = Contains("value")
|
expected = Contains("value")
|
||||||
@@ -191,8 +223,7 @@ def test_i_can_output_error_when_predicate_wrong_value():
|
|||||||
assert error_output.output == ["before after",
|
assert error_output.output == ["before after",
|
||||||
"^^^^^^^^^^^^"]
|
"^^^^^^^^^^^^"]
|
||||||
|
|
||||||
|
def test_i_can_output_error_child_element(self):
|
||||||
def test_i_can_output_error_child_element():
|
|
||||||
"""I can display error when the element has children"""
|
"""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")
|
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||||
expected = elt
|
expected = elt
|
||||||
@@ -206,8 +237,7 @@ def test_i_can_output_error_child_element():
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_i_can_output_error_child_element_text(self):
|
||||||
def test_i_can_output_error_child_element_text():
|
|
||||||
"""I can display error when the children is not a FT"""
|
"""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")
|
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||||
expected = elt
|
expected = elt
|
||||||
@@ -221,8 +251,7 @@ def test_i_can_output_error_child_element_text():
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_i_can_output_error_child_element_indicating_sub_children(self):
|
||||||
def test_i_can_output_error_child_element_indicating_sub_children():
|
|
||||||
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
|
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
|
||||||
expected = elt
|
expected = elt
|
||||||
path = ""
|
path = ""
|
||||||
@@ -234,8 +263,7 @@ def test_i_can_output_error_child_element_indicating_sub_children():
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_i_can_output_error_child_element_wrong_value(self):
|
||||||
def test_i_can_output_error_child_element_wrong_value():
|
|
||||||
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
|
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
|
||||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -248,8 +276,7 @@ def test_i_can_output_error_child_element_wrong_value():
|
|||||||
')',
|
')',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def test_i_can_output_error_fewer_elements(self):
|
||||||
def test_i_can_output_error_fewer_elements():
|
|
||||||
elt = Div(P(id="p_id"), attr1="value1")
|
elt = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||||
path = ""
|
path = ""
|
||||||
@@ -261,8 +288,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")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = actual
|
expected = actual
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -277,8 +357,7 @@ def test_i_can_output_comparison():
|
|||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
|
def test_i_can_output_comparison_with_path(self):
|
||||||
def test_i_can_output_comparison_with_path():
|
|
||||||
actual = Div(P(id="p_id"), attr1="value1")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = actual
|
expected = actual
|
||||||
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
|
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
|
||||||
@@ -295,8 +374,7 @@ def test_i_can_output_comparison_with_path():
|
|||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
|
def test_i_can_output_comparison_when_missing_attributes(self):
|
||||||
def test_i_can_output_comparison_when_missing_attributes():
|
|
||||||
actual = Div(P(id="p_id"), attr1="value1")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = Div(P(id="p_id"), attr2="value1")
|
expected = Div(P(id="p_id"), attr2="value1")
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -312,8 +390,7 @@ def test_i_can_output_comparison_when_missing_attributes():
|
|||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
|
def test_i_can_output_comparison_when_wrong_attributes(self):
|
||||||
def test_i_can_output_comparison_when_wrong_attributes():
|
|
||||||
actual = Div(P(id="p_id"), attr1="value2")
|
actual = Div(P(id="p_id"), attr1="value2")
|
||||||
expected = Div(P(id="p_id"), attr1="value1")
|
expected = Div(P(id="p_id"), attr1="value1")
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -329,8 +406,7 @@ def test_i_can_output_comparison_when_wrong_attributes():
|
|||||||
(p "id"="p_id") | (p "id"="p_id")
|
(p "id"="p_id") | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
|
def test_i_can_output_comparison_when_fewer_elements(self):
|
||||||
def test_i_can_output_comparison_when_fewer_elements():
|
|
||||||
actual = Div(P(id="p_id"), attr1="value1")
|
actual = Div(P(id="p_id"), attr1="value1")
|
||||||
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
|
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
|
||||||
actual_out = ErrorOutput("", actual, expected)
|
actual_out = ErrorOutput("", actual, expected)
|
||||||
@@ -347,8 +423,7 @@ def test_i_can_output_comparison_when_fewer_elements():
|
|||||||
! ** MISSING ** ! | (p "id"="p_id")
|
! ** MISSING ** ! | (p "id"="p_id")
|
||||||
) | )'''
|
) | )'''
|
||||||
|
|
||||||
|
def test_i_can_see_the_diff_when_matching(self):
|
||||||
def test_i_can_see_the_diff_when_matching():
|
|
||||||
actual = Div(attr1="value1")
|
actual = Div(attr1="value1")
|
||||||
expected = Div(attr1=Contains("value2"))
|
expected = Div(attr1=Contains("value2"))
|
||||||
|
|
||||||
@@ -361,3 +436,35 @@ Path : 'div'
|
|||||||
Error : The condition 'Contains(value2)' is not satisfied.
|
Error : The condition 'Contains(value2)' is not satisfied.
|
||||||
(div "attr1"="value1") | (div "attr1"="Contains(value2)")
|
(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")
|
||||||
|
^^^ |'''
|
||||||
|
|
||||||
|
|
||||||
|
class TestPredicates:
|
||||||
|
def test_i_can_validate_contains_with_words_only(self):
|
||||||
|
assert Contains("value", _word=True).validate("value value2 value3")
|
||||||
|
assert Contains("value", "value2", _word=True).validate("value value2 value3")
|
||||||
|
|
||||||
|
assert not Contains("value", _word=True).validate("valuevalue2value3")
|
||||||
|
assert not Contains("value value2", _word=True).validate("value value2 value3")
|
||||||
|
|
||||||
|
def test_i_can_validate_has_htmx(self):
|
||||||
|
div = Div(hx_post="/url")
|
||||||
|
assert HasHtmx(hx_post="/url").validate(div)
|
||||||
|
|
||||||
|
c = Command("c", "testing has_htmx", None)
|
||||||
|
c.bind_ft(div)
|
||||||
|
assert HasHtmx(command=c).validate(div)
|
||||||
|
|||||||
Reference in New Issue
Block a user