Compare commits
9 Commits
AddingTree
...
7238cb085e
| Author | SHA1 | Date | |
|---|---|---|---|
| 7238cb085e | |||
| fb57a6a81d | |||
| 7f56b89e66 | |||
| cba4f2aab4 | |||
| c641f3fd63 | |||
| d302261d07 | |||
| a547b2b882 | |||
| 3d46e092aa | |||
| 5cb628099a |
@@ -1,242 +0,0 @@
|
||||
# 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
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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
|
||||
@@ -1,64 +0,0 @@
|
||||
# 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
|
||||
@@ -1,230 +0,0 @@
|
||||
# Unit Tester Mode
|
||||
|
||||
You are now in **Unit Tester Mode** - specialized mode for writing unit tests for existing code in the MyFastHtml project.
|
||||
|
||||
## Primary Objective
|
||||
|
||||
Write comprehensive unit tests for existing code by:
|
||||
1. Analyzing the code to understand its behavior
|
||||
2. Identifying test cases (success paths and edge cases)
|
||||
3. Proposing test plan for validation
|
||||
4. Implementing tests only after approval
|
||||
|
||||
## Unit Test Rules (UTR)
|
||||
|
||||
### UTR-1: Test Analysis Before Implementation
|
||||
|
||||
Before writing any tests:
|
||||
1. **Check for existing tests first** - Look for corresponding test file (e.g., `src/foo/bar.py` → `tests/foo/test_bar.py`)
|
||||
2. **Analyze the code thoroughly** - Read and understand the implementation
|
||||
3. **If tests exist**: Identify what's already covered and what's missing
|
||||
4. **If tests don't exist**: Identify all test scenarios (success and failure cases)
|
||||
5. **Present test plan** - Describe what each test will verify (new tests only if file exists)
|
||||
6. **Wait for validation** - Only proceed after explicit approval
|
||||
|
||||
### UTR-2: Test Naming Conventions
|
||||
|
||||
- **Passing tests**: `test_i_can_xxx` - Tests that should succeed
|
||||
- **Failing tests**: `test_i_cannot_xxx` - Edge cases that should raise errors/exceptions
|
||||
|
||||
**Example:**
|
||||
```python
|
||||
def test_i_can_create_command_with_valid_name():
|
||||
"""Test that a command can be created with a valid name."""
|
||||
cmd = Command("valid_name", "description", lambda: None)
|
||||
assert cmd.name == "valid_name"
|
||||
|
||||
def test_i_cannot_create_command_with_empty_name():
|
||||
"""Test that creating a command with empty name raises ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
Command("", "description", lambda: None)
|
||||
```
|
||||
|
||||
### UTR-3: Use Functions, Not Classes (Default)
|
||||
|
||||
- Use **functions** for tests by default
|
||||
- Only use classes when inheritance or grouping is required (see UTR-10)
|
||||
- Before writing tests, **list all planned tests with explanations**
|
||||
- Wait for validation before implementing tests
|
||||
|
||||
### UTR-4: Do NOT Test Python Built-ins
|
||||
|
||||
**Do NOT test Python's built-in functionality.**
|
||||
|
||||
❌ **Bad example - Testing Python list behavior:**
|
||||
```python
|
||||
def test_i_can_add_child_to_node(self):
|
||||
"""Test that we can add a child ID to the children list."""
|
||||
parent_node = TreeNode(label="Parent", type="folder")
|
||||
child_id = "child_123"
|
||||
|
||||
parent_node.children.append(child_id) # Just testing list.append()
|
||||
|
||||
assert child_id in parent_node.children # Just testing list membership
|
||||
```
|
||||
|
||||
This test validates that Python's `list.append()` works correctly, which is not our responsibility.
|
||||
|
||||
✅ **Good example - Testing business logic:**
|
||||
```python
|
||||
def test_i_can_add_child_node(self, root_instance):
|
||||
"""Test adding a child node to a parent."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id) # Testing OUR method
|
||||
|
||||
assert child.id in tree_view._state.items # Verify state updated
|
||||
assert child.id in parent.children # Verify relationship established
|
||||
assert child.parent == parent.id # Verify bidirectional link
|
||||
```
|
||||
|
||||
This test validates the `add_node()` method's logic: state management, relationship creation, bidirectional linking.
|
||||
|
||||
**Other examples of what NOT to test:**
|
||||
- Setting/getting attributes: `obj.value = 5; assert obj.value == 5`
|
||||
- Dictionary operations: `d["key"] = "value"; assert "key" in d`
|
||||
- String concatenation: `result = "hello" + "world"; assert result == "helloworld"`
|
||||
- Type checking: `assert isinstance(obj, MyClass)` (unless type validation is part of your logic)
|
||||
|
||||
### UTR-5: Test Business Logic Only
|
||||
|
||||
**What TO test:**
|
||||
- Your business logic and algorithms
|
||||
- Your validation rules
|
||||
- Your state transformations
|
||||
- Your integration between components
|
||||
- Your error handling for invalid inputs
|
||||
- Your side effects (database updates, command registration, etc.)
|
||||
|
||||
### UTR-6: Test Coverage Requirements
|
||||
|
||||
For each code element, consider testing:
|
||||
|
||||
**Functions/Methods:**
|
||||
- Valid inputs (typical use cases)
|
||||
- Edge cases (empty values, None, boundaries)
|
||||
- Error conditions (invalid inputs, exceptions)
|
||||
- Return values and side effects
|
||||
|
||||
**Classes:**
|
||||
- Initialization (default values, custom values)
|
||||
- State management (attributes, properties)
|
||||
- Methods (all public methods)
|
||||
- Integration (interactions with other classes)
|
||||
|
||||
**Components (Controls):**
|
||||
- Creation and initialization
|
||||
- State changes
|
||||
- Commands and their effects
|
||||
- Rendering (if applicable)
|
||||
- Edge cases and error conditions
|
||||
|
||||
### UTR-7: Ask Questions One at a Time
|
||||
|
||||
**Ask questions to clarify understanding:**
|
||||
- Ask questions **one at a time**
|
||||
- Wait for complete answer before asking the next question
|
||||
- Indicate progress: "Question 1/5" if multiple questions are needed
|
||||
- Never assume behavior - always verify understanding
|
||||
|
||||
### UTR-8: Communication Language
|
||||
|
||||
**Conversations**: French or English (match user's language)
|
||||
**Code, documentation, comments**: English only
|
||||
|
||||
### UTR-9: Code Standards
|
||||
|
||||
**Follow PEP 8** conventions strictly:
|
||||
- Variable and function names: `snake_case`
|
||||
- Explicit, descriptive naming
|
||||
- **No emojis in code**
|
||||
|
||||
**Documentation**:
|
||||
- Use Google or NumPy docstring format
|
||||
- Every test should have a clear docstring explaining what it verifies
|
||||
- Include type hints where applicable
|
||||
|
||||
### UTR-10: Test File Organization
|
||||
|
||||
**File paths:**
|
||||
- Always specify the full file path when creating test files
|
||||
- Mirror source structure: `src/myfasthtml/core/commands.py` → `tests/core/test_commands.py`
|
||||
|
||||
**Example:**
|
||||
```
|
||||
✅ Creating: tests/core/test_new_feature.py
|
||||
✅ Modifying: tests/controls/test_treeview.py
|
||||
```
|
||||
|
||||
**Test organization for Controls:**
|
||||
|
||||
Controls are classes with `__ft__()` and `render()` methods. For these components, organize tests into thematic classes:
|
||||
|
||||
```python
|
||||
class TestControlBehaviour:
|
||||
"""Tests for control behavior and logic."""
|
||||
|
||||
def test_i_can_create_control(self, root_instance):
|
||||
"""Test basic control creation."""
|
||||
control = MyControl(root_instance)
|
||||
assert control is not None
|
||||
|
||||
def test_i_can_update_state(self, root_instance):
|
||||
"""Test state management."""
|
||||
# Test state changes, data updates, etc.
|
||||
pass
|
||||
|
||||
class TestControlRender:
|
||||
"""Tests for control HTML rendering."""
|
||||
|
||||
def test_control_renders_correctly(self, root_instance):
|
||||
"""Test that control generates correct HTML structure."""
|
||||
# Test HTML output, attributes, classes, etc.
|
||||
pass
|
||||
|
||||
def test_control_renders_with_custom_config(self, root_instance):
|
||||
"""Test rendering with custom configuration."""
|
||||
# Test different rendering scenarios
|
||||
pass
|
||||
```
|
||||
|
||||
**Why separate behaviour and render tests:**
|
||||
- **Behaviour tests**: Focus on logic, state management, commands, and interactions
|
||||
- **Render tests**: Focus on HTML structure, attributes, and visual representation
|
||||
- **Clarity**: Makes it clear what aspect of the control is being tested
|
||||
- **Maintenance**: Easier to locate and update tests when behaviour or rendering changes
|
||||
|
||||
**Note:** This organization applies **only to controls** (components with rendering capabilities). For other classes (core logic, utilities, etc.), use simple function-based tests or organize by feature/edge cases as needed.
|
||||
|
||||
### UTR-11: Test Workflow
|
||||
|
||||
1. **Receive code to test** - User provides file path or code section
|
||||
2. **Check existing tests** - Look for corresponding test file and read it if it exists
|
||||
3. **Analyze code** - Read and understand implementation
|
||||
4. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
|
||||
5. **Propose test plan** - List new/missing tests with brief explanations
|
||||
6. **Wait for approval** - User validates the test plan
|
||||
7. **Implement tests** - Write all approved tests
|
||||
8. **Verify** - Ensure tests follow naming conventions and structure
|
||||
9. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
|
||||
|
||||
## Managing Rules
|
||||
|
||||
To disable a specific rule, the user can say:
|
||||
- "Disable UTR-4" (do not apply the rule about testing Python built-ins)
|
||||
- "Enable UTR-4" (re-enable a previously disabled rule)
|
||||
|
||||
When a rule is disabled, acknowledge it and adapt behavior accordingly.
|
||||
|
||||
## Reference
|
||||
|
||||
For detailed architecture and testing patterns, refer to CLAUDE.md in the project root.
|
||||
|
||||
## Other Personas
|
||||
|
||||
- Use `/developer` to switch to development mode
|
||||
- Use `/technical-writer` to switch to documentation mode
|
||||
- Use `/reset` to return to default Claude Code mode
|
||||
455
CLAUDE.md
455
CLAUDE.md
@@ -1,455 +0,0 @@
|
||||
# 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
|
||||
2
Makefile
2
Makefile
@@ -18,7 +18,6 @@ clean-tests:
|
||||
rm -rf .sesskey
|
||||
rm -rf tests/.sesskey
|
||||
rm -rf tests/*.db
|
||||
rm -rf tests/.myFastHtmlDb
|
||||
|
||||
# Alias to clean everything
|
||||
clean: clean-build clean-tests
|
||||
@@ -26,4 +25,3 @@ clean: clean-build clean-tests
|
||||
clean-all : clean
|
||||
rm -rf src/.sesskey
|
||||
rm -rf src/Users.db
|
||||
rm -rf src/.myFastHtmlDb
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
# Keyboard Support - Test Instructions
|
||||
|
||||
## ⚠️ Breaking Change
|
||||
|
||||
**Version 2.0** uses HTMX configuration objects instead of simple URL strings. The old format is **not supported**.
|
||||
|
||||
**Old format (no longer supported)**:
|
||||
```javascript
|
||||
{"A": "/url"}
|
||||
```
|
||||
|
||||
**New format (required)**:
|
||||
```javascript
|
||||
{"A": {"hx-post": "/url"}}
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `keyboard_support.js` - Main keyboard support library with smart timeout logic
|
||||
- `test_keyboard_support.html` - Test page to verify functionality
|
||||
|
||||
## Key Features
|
||||
|
||||
### Multiple Simultaneous Triggers
|
||||
|
||||
**IMPORTANT**: If multiple elements listen to the same combination, **ALL** of them will be triggered:
|
||||
|
||||
```javascript
|
||||
add_keyboard_support('modal', '{"esc": "/close-modal"}');
|
||||
add_keyboard_support('editor', '{"esc": "/cancel-edit"}');
|
||||
add_keyboard_support('sidebar', '{"esc": "/hide-sidebar"}');
|
||||
|
||||
// Pressing ESC will trigger all 3 URLs simultaneously
|
||||
```
|
||||
|
||||
This is crucial for use cases like the ESC key, which often needs to cancel multiple actions at once (close modal, cancel edit, hide panels, etc.).
|
||||
|
||||
### Smart Timeout Logic (Longest Match)
|
||||
|
||||
The library uses **a single global timeout** based on the sequence state, not on individual elements:
|
||||
|
||||
**Key principle**: If **any element** has a longer sequence possible, **all matching elements wait**.
|
||||
|
||||
Examples:
|
||||
|
||||
**Example 1**: Three elements, same combination
|
||||
```javascript
|
||||
add_keyboard_support('elem1', '{"esc": "/url1"}');
|
||||
add_keyboard_support('elem2', '{"esc": "/url2"}');
|
||||
add_keyboard_support('elem3', '{"esc": "/url3"}');
|
||||
// Press ESC → ALL 3 trigger immediately (no longer sequences exist)
|
||||
```
|
||||
|
||||
**Example 2**: Mixed - one has longer sequence
|
||||
```javascript
|
||||
add_keyboard_support('elem1', '{"A": "/url1"}');
|
||||
add_keyboard_support('elem2', '{"A": "/url2"}');
|
||||
add_keyboard_support('elem3', '{"A": "/url3", "A B": "/url3b"}');
|
||||
// Press A:
|
||||
// - elem3 has "A B" possible → EVERYONE WAITS 500ms
|
||||
// - If B arrives: only elem3 triggers with "A B"
|
||||
// - If timeout expires: elem1, elem2, elem3 ALL trigger with "A"
|
||||
```
|
||||
|
||||
**Example 3**: Different combinations
|
||||
```javascript
|
||||
add_keyboard_support('elem1', '{"A B": "/url1"}');
|
||||
add_keyboard_support('elem2', '{"C D": "/url2"}');
|
||||
// Press A: elem1 waits for B, elem2 not affected
|
||||
// Press C: elem2 waits for D, elem1 not affected
|
||||
```
|
||||
|
||||
The timeout is tied to the **sequence being typed**, not to individual elements.
|
||||
|
||||
### Smart Timeout Logic (Longest Match)
|
||||
|
||||
Keyboard shortcuts are **disabled** when typing in input fields:
|
||||
- `<input>` elements
|
||||
- `<textarea>` elements
|
||||
- Any `contenteditable` element
|
||||
|
||||
This ensures normal typing (Ctrl+C, Ctrl+A, etc.) works as expected in forms.
|
||||
|
||||
## How to Test
|
||||
|
||||
1. **Download both files** to the same directory
|
||||
2. **Open `test_keyboard_support.html`** in a web browser
|
||||
3. **Try the configured shortcuts**:
|
||||
- `a` - Simple key (waits if "A B" might follow)
|
||||
- `Ctrl+S` - Save combination (immediate)
|
||||
- `Ctrl+C` - Copy combination (waits because "Ctrl+C C" exists)
|
||||
- `A B` - Sequence (waits because "A B C" exists)
|
||||
- `A B C` - Triple sequence (triggers immediately)
|
||||
- `Ctrl+C C` - Press Ctrl+C together, release, then press C alone
|
||||
- `Ctrl+C Ctrl+C` - Press Ctrl+C, keep Ctrl, release C, press C again
|
||||
- `shift shift` - Press Shift twice in sequence
|
||||
- `esc` - Escape key (immediate)
|
||||
|
||||
4. **Test focus behavior**:
|
||||
- Click on the test element to focus it (turns blue)
|
||||
- Try shortcuts with focus
|
||||
- Click outside to remove focus
|
||||
- Try shortcuts without focus
|
||||
- The log shows whether the element had focus when triggered
|
||||
|
||||
5. **Test input protection**:
|
||||
- Try typing in the input field
|
||||
- Use Ctrl+C, Ctrl+A, etc. - should work normally
|
||||
- Shortcuts should NOT trigger while typing in input
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
### Smart Timeout Examples
|
||||
|
||||
**Scenario 1**: Only "A" is configured (no element has "A B")
|
||||
- Press A → Triggers **immediately**
|
||||
|
||||
**Scenario 2**: At least one element has "A B"
|
||||
- Press A → **ALL elements with "A" wait 500ms**
|
||||
- If B pressed within 500ms → Only elements with "A B" trigger
|
||||
- If timeout expires → ALL elements with "A" trigger
|
||||
|
||||
**Scenario 3**: "A", "A B", and "A B C" all configured (same or different elements)
|
||||
- Press A → Waits (because "A B" exists)
|
||||
- Press B → Waits (because "A B C" exists)
|
||||
- Press C → Triggers "A B C" **immediately**
|
||||
|
||||
**Scenario 4**: Multiple elements, ESC on all
|
||||
```javascript
|
||||
add_keyboard_support('modal', '{"esc": "/close"}');
|
||||
add_keyboard_support('editor', '{"esc": "/cancel"}');
|
||||
```
|
||||
- Press ESC → **Both trigger simultaneously** (no longer sequences)
|
||||
|
||||
## Integration in Your Project
|
||||
|
||||
## Integration in Your Project
|
||||
|
||||
### Configuration Format
|
||||
|
||||
The library now uses **HTMX configuration objects** instead of simple URL strings:
|
||||
|
||||
```python
|
||||
# New format with HTMX configuration
|
||||
combinations = {
|
||||
"Ctrl+S": {
|
||||
"hx-post": "/save-url",
|
||||
"hx-target": "#result",
|
||||
"hx-swap": "innerHTML"
|
||||
},
|
||||
"A B": {
|
||||
"hx-post": "/sequence-url",
|
||||
"hx-vals": {"extra": "data"}
|
||||
},
|
||||
"esc": {
|
||||
"hx-get": "/cancel-url"
|
||||
}
|
||||
}
|
||||
|
||||
# This will generate the JavaScript call
|
||||
f"add_keyboard_support('{element_id}', '{json.dumps(combinations)}')"
|
||||
```
|
||||
|
||||
### Supported HTMX Attributes
|
||||
|
||||
You can use any HTMX attribute in the configuration object:
|
||||
|
||||
**HTTP Methods** (one required):
|
||||
- `hx-post` - POST request
|
||||
- `hx-get` - GET request
|
||||
- `hx-put` - PUT request
|
||||
- `hx-delete` - DELETE request
|
||||
- `hx-patch` - PATCH request
|
||||
|
||||
**Common Options**:
|
||||
- `hx-target` - Target element selector
|
||||
- `hx-swap` - Swap strategy (innerHTML, outerHTML, etc.)
|
||||
- `hx-vals` - Additional values to send (object)
|
||||
- `hx-headers` - Custom headers (object)
|
||||
- `hx-select` - Select specific content from response
|
||||
- `hx-confirm` - Confirmation message
|
||||
|
||||
All other `hx-*` attributes are supported and will be converted to the appropriate htmx.ajax() parameters.
|
||||
|
||||
### Automatic Parameters
|
||||
|
||||
The library automatically adds these parameters to every request:
|
||||
- `combination` - The combination that triggered the action (e.g., "Ctrl+S")
|
||||
- `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:
|
||||
```javascript
|
||||
htmx.ajax('POST', '/save-url', {
|
||||
target: '#result',
|
||||
swap: 'innerHTML',
|
||||
values: {
|
||||
extra: "data", // from hx-vals
|
||||
combination: "Ctrl+S", // automatic
|
||||
has_focus: true, // automatic
|
||||
is_inside: true // automatic
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Integration Examples
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"Ctrl+S": {
|
||||
"hx-post": "/save"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Target and Swap
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"Ctrl+D": {
|
||||
"hx-delete": "/item",
|
||||
"hx-target": "#item-list",
|
||||
"hx-swap": "outerHTML"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Extra Values
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"Ctrl+N": {
|
||||
"hx-post": "/create",
|
||||
"hx-vals": json.dumps({"type": "quick", "mode": "keyboard"})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Elements Example
|
||||
|
||||
```python
|
||||
# Modal close
|
||||
modal_combinations = {
|
||||
"esc": {
|
||||
"hx-post": "/modal/close",
|
||||
"hx-target": "#modal",
|
||||
"hx-swap": "outerHTML"
|
||||
}
|
||||
}
|
||||
|
||||
# Editor cancel
|
||||
editor_combinations = {
|
||||
"esc": {
|
||||
"hx-post": "/editor/cancel",
|
||||
"hx-target": "#editor",
|
||||
"hx-swap": "innerHTML"
|
||||
}
|
||||
}
|
||||
|
||||
# Both will trigger when ESC is pressed
|
||||
f"add_keyboard_support('modal', '{json.dumps(modal_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
|
||||
|
||||
### Trie-based Matching
|
||||
|
||||
The library uses a prefix tree (trie) data structure:
|
||||
- Each node represents a keyboard snapshot (set of pressed keys)
|
||||
- Leaf nodes contain the HTMX configuration object
|
||||
- Intermediate nodes indicate longer sequences exist
|
||||
- Enables efficient O(n) matching where n is sequence length
|
||||
|
||||
### HTMX Integration
|
||||
|
||||
Configuration objects are mapped to htmx.ajax() calls:
|
||||
- `hx-*` attributes are converted to camelCase parameters
|
||||
- HTTP method is extracted from `hx-post`, `hx-get`, etc.
|
||||
- `combination`, `has_focus`, and `is_inside` are automatically added to values
|
||||
- All standard HTMX options are supported
|
||||
|
||||
### Key Normalization
|
||||
|
||||
- Case-insensitive: "ctrl" = "Ctrl" = "CTRL"
|
||||
- Mapped keys: "Control" → "ctrl", "Escape" → "esc", "Delete" → "del"
|
||||
- Simultaneous keys represented as sorted sets
|
||||
|
||||
## Notes
|
||||
|
||||
- The test page mocks `htmx.ajax` to display results in the console
|
||||
- In production, real AJAX calls will be made to your backend
|
||||
- Sequence timeout is 500ms between keys
|
||||
- Maximum 10 snapshots kept in history to prevent memory issues
|
||||
@@ -1,439 +0,0 @@
|
||||
# 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
|
||||
@@ -16,7 +16,6 @@ dnspython==2.8.0
|
||||
docutils==0.22.2
|
||||
ecdsa==0.19.1
|
||||
email-validator==2.3.0
|
||||
et_xmlfile==2.0.0
|
||||
fastapi==0.120.0
|
||||
fastcore==1.8.13
|
||||
fastlite==0.2.1
|
||||
@@ -36,15 +35,12 @@ keyring==25.6.0
|
||||
markdown-it-py==4.0.0
|
||||
mdurl==0.1.2
|
||||
more-itertools==10.8.0
|
||||
myauth==0.2.1
|
||||
myauth==0.2.0
|
||||
mydbengine==0.1.0
|
||||
myutils==0.4.0
|
||||
nh3==0.3.1
|
||||
numpy==2.3.5
|
||||
oauthlib==3.3.1
|
||||
openpyxl==3.1.5
|
||||
packaging==25.0
|
||||
pandas==2.3.3
|
||||
passlib==1.7.4
|
||||
pipdeptree==2.29.0
|
||||
pluggy==1.6.0
|
||||
@@ -61,7 +57,6 @@ python-dotenv==1.1.1
|
||||
python-fasthtml==0.12.30
|
||||
python-jose==3.5.0
|
||||
python-multipart==0.0.20
|
||||
pytz==2025.2
|
||||
PyYAML==6.0.3
|
||||
readme_renderer==44.0
|
||||
requests==2.32.5
|
||||
@@ -79,7 +74,6 @@ twine==6.2.0
|
||||
typer==0.20.0
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
tzdata==2025.2
|
||||
urllib3==2.5.0
|
||||
uvicorn==0.38.0
|
||||
uvloop==0.22.1
|
||||
|
||||
128
src/app.py
128
src/app.py
@@ -1,131 +1,53 @@
|
||||
import logging.config
|
||||
import logging
|
||||
|
||||
import yaml
|
||||
from fasthtml import serve
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
from myfasthtml.controls.FileUpload import FileUpload
|
||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.controls.helpers import Ids, mk
|
||||
from myfasthtml.core.instances import UniqueInstance
|
||||
from myfasthtml.icons.carbon import volume_object_storage
|
||||
from myfasthtml.icons.fluent_p2 import key_command16_regular
|
||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import InstancesManager
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
with open('logging.yaml', 'r') as f:
|
||||
config = yaml.safe_load(f)
|
||||
|
||||
# At the top of your script or module
|
||||
logging.config.dictConfig(config)
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG, # Set logging level to DEBUG
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
|
||||
datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format
|
||||
)
|
||||
|
||||
app, rt = create_app(protect_routes=True,
|
||||
mount_auth_app=True,
|
||||
pico=False,
|
||||
vis=True,
|
||||
title="MyFastHtml",
|
||||
live=True,
|
||||
base_url="http://localhost:5003")
|
||||
|
||||
|
||||
def create_sample_treeview(parent):
|
||||
"""
|
||||
Create a sample TreeView with a file structure for testing.
|
||||
|
||||
Args:
|
||||
parent: Parent instance for the TreeView
|
||||
|
||||
Returns:
|
||||
TreeView: Configured TreeView instance with sample data
|
||||
"""
|
||||
tree_view = TreeView(parent, _id="-treeview")
|
||||
|
||||
# Create sample file structure
|
||||
projects = TreeNode(label="Projects", type="folder")
|
||||
tree_view.add_node(projects)
|
||||
|
||||
myfasthtml = TreeNode(label="MyFastHtml", type="folder")
|
||||
tree_view.add_node(myfasthtml, parent_id=projects.id)
|
||||
|
||||
app_py = TreeNode(label="app.py", type="file")
|
||||
tree_view.add_node(app_py, parent_id=myfasthtml.id)
|
||||
|
||||
readme = TreeNode(label="README.md", type="file")
|
||||
tree_view.add_node(readme, parent_id=myfasthtml.id)
|
||||
|
||||
src_folder = TreeNode(label="src", type="folder")
|
||||
tree_view.add_node(src_folder, parent_id=myfasthtml.id)
|
||||
|
||||
controls_py = TreeNode(label="controls.py", type="file")
|
||||
tree_view.add_node(controls_py, parent_id=src_folder.id)
|
||||
|
||||
documents = TreeNode(label="Documents", type="folder")
|
||||
tree_view.add_node(documents, parent_id=projects.id)
|
||||
|
||||
notes = TreeNode(label="notes.txt", type="file")
|
||||
tree_view.add_node(notes, parent_id=documents.id)
|
||||
|
||||
todo = TreeNode(label="todo.md", type="file")
|
||||
tree_view.add_node(todo, parent_id=documents.id)
|
||||
|
||||
# Expand all nodes to show the full structure
|
||||
#tree_view.expand_all()
|
||||
|
||||
return tree_view
|
||||
|
||||
|
||||
@rt("/")
|
||||
def index(session):
|
||||
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
|
||||
layout = Layout(session_instance, "Testing Layout")
|
||||
layout = InstancesManager.get(session, Ids.Layout, Layout, "Testing Layout")
|
||||
layout.set_footer("Goodbye World")
|
||||
for i in range(1000):
|
||||
layout.left_drawer.append(Div(f"Left Drawer Item {i}"))
|
||||
layout.right_drawer.append(Div(f"Left Drawer Item {i}"))
|
||||
|
||||
tabs_manager = TabsManager(session, _id="main")
|
||||
btn = mk.button("Add Tab",
|
||||
command=Command("AddTab",
|
||||
"Add a new tab",
|
||||
tabs_manager.on_new_tab, "Tabs", Div("Content")).
|
||||
htmx(target=f"#{tabs_manager.get_id()}"))
|
||||
|
||||
tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
|
||||
add_tab = tabs_manager.commands.add_tab
|
||||
btn_show_right_drawer = mk.button("show",
|
||||
command=layout.commands.toggle_drawer("right"),
|
||||
command=Command("ShowRightDrawer",
|
||||
"Show Right Drawer",
|
||||
layout.toggle_drawer, "right"),
|
||||
id="btn_show_right_drawer_id")
|
||||
|
||||
instances_debugger = InstancesDebugger(layout)
|
||||
btn_show_instances_debugger = mk.label("Instances",
|
||||
icon=volume_object_storage,
|
||||
command=add_tab("Instances", instances_debugger),
|
||||
id=instances_debugger.get_id())
|
||||
|
||||
commands_debugger = CommandsDebugger(layout)
|
||||
btn_show_commands_debugger = mk.label("Commands",
|
||||
icon=key_command16_regular,
|
||||
command=add_tab("Commands", commands_debugger),
|
||||
id=commands_debugger.get_id())
|
||||
|
||||
btn_file_upload = mk.label("Upload",
|
||||
icon=folder_open20_regular,
|
||||
command=add_tab("File Open", FileUpload(layout, _id="-file_upload")),
|
||||
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_right.add(btn_show_right_drawer)
|
||||
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
|
||||
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
|
||||
layout.left_drawer.add(btn_file_upload, "Test")
|
||||
layout.left_drawer.add(btn_popup, "Test")
|
||||
layout.left_drawer.add(tree_view, "TreeView")
|
||||
layout.set_footer(btn_show_right_drawer)
|
||||
layout.set_main(tabs_manager)
|
||||
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
version: 1
|
||||
disable_existing_loggers: False
|
||||
|
||||
formatters:
|
||||
default:
|
||||
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
handlers:
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: default
|
||||
|
||||
root:
|
||||
level: DEBUG
|
||||
handlers: [console]
|
||||
|
||||
|
||||
loggers:
|
||||
# Explicit logger configuration (example)
|
||||
multipart.multipart:
|
||||
level: INFO
|
||||
handlers: [console]
|
||||
propagate: False
|
||||
python_multipart.multipart:
|
||||
level: INFO
|
||||
handlers: [ console ]
|
||||
propagate: False
|
||||
httpcore:
|
||||
level: ERROR
|
||||
handlers: [ console ]
|
||||
propagate: False
|
||||
httpx:
|
||||
level: INFO
|
||||
handlers: [ console ]
|
||||
propagate: False
|
||||
watchfiles.main:
|
||||
level: ERROR
|
||||
handlers: [console]
|
||||
propagate: False
|
||||
|
||||
dbengine.dbengine:
|
||||
level: ERROR
|
||||
handlers: [console]
|
||||
propagate: False
|
||||
|
||||
passlib.registry:
|
||||
level: ERROR
|
||||
handlers: [console]
|
||||
propagate: False
|
||||
|
||||
socket.socket:
|
||||
level: ERROR
|
||||
handlers: [ console ]
|
||||
propagate: False
|
||||
@@ -1,53 +1,17 @@
|
||||
:root {
|
||||
--color-border-primary: color-mix(in oklab, var(--color-primary) 40%, #0000);
|
||||
--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;
|
||||
--spacing: 0.25rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
--text-xl: 1.25rem;
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--font-weight-medium: 500;
|
||||
--radius-md: 0.375rem;
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
.mf-icon-20 {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
|
||||
.mf-icon-16 {
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mf-icon-20 {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mf-icon-24 {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
margin-top: auto;
|
||||
|
||||
}
|
||||
|
||||
.mf-icon-28 {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.mf-icon-32 {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
margin-top: auto;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -117,14 +81,13 @@
|
||||
/* Left drawer */
|
||||
.mf-layout-left-drawer {
|
||||
grid-area: left-drawer;
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
border-right: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
|
||||
}
|
||||
|
||||
/* Right drawer */
|
||||
.mf-layout-right-drawer {
|
||||
grid-area: right-drawer;
|
||||
/*border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);*/
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
border-left: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
|
||||
}
|
||||
|
||||
/* Collapsed drawer states */
|
||||
@@ -208,7 +171,11 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Layout Drawer Resizer Styles
|
||||
*
|
||||
* Styles for the resizable drawer borders with visual feedback
|
||||
*/
|
||||
/**
|
||||
* Layout Drawer Resizer Styles
|
||||
*
|
||||
@@ -230,7 +197,6 @@
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Base resizer styles */
|
||||
@@ -311,16 +277,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
.mf-layout-group {
|
||||
font-weight: bold;
|
||||
/*font-size: var(--text-sm);*/
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* *********** Tabs Manager Component ************ */
|
||||
/* *********************************************** */
|
||||
@@ -340,21 +296,8 @@
|
||||
.mf-tabs-header {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex-shrink: 1;
|
||||
flex-shrink: 0;
|
||||
min-height: 25px;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mf-tabs-header-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
/*overflow: hidden; important */
|
||||
}
|
||||
|
||||
/* Individual Tab Button using DaisyUI tab classes */
|
||||
@@ -376,7 +319,6 @@
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
border-radius: .25rem;
|
||||
border-bottom: 4px solid var(--color-primary);
|
||||
box-shadow: 0 1px oklch(100% 0 0/calc(var(--depth) * .1)) inset, 0 1px 1px -1px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 50%), #0000), 0 1px 6px -4px color-mix(in oklab, var(--color-neutral) calc(var(--depth) * 100%), #0000);
|
||||
}
|
||||
|
||||
@@ -407,17 +349,10 @@
|
||||
|
||||
/* Tab Content Area */
|
||||
.mf-tab-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mf-tab-content-wrapper {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background-color: var(--color-base-100);
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Empty Content State */
|
||||
@@ -428,247 +363,4 @@
|
||||
height: 100%;
|
||||
@apply text-base-content/50;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.mf-vis {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mf-search-results {
|
||||
margin-top: 0.5rem;
|
||||
max-height: 200px;
|
||||
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 *************** */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Container principal du panel */
|
||||
.mf-panel {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Panel gauche */
|
||||
.mf-panel-left {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 250px;
|
||||
min-width: 150px;
|
||||
max-width: 400px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* Panel principal (centre) */
|
||||
.mf-panel-main {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
min-width: 0; /* Important pour permettre le shrink du flexbox */
|
||||
}
|
||||
|
||||
/* Panel droit */
|
||||
.mf-panel-right {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 300px;
|
||||
min-width: 150px;
|
||||
max-width: 500px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
34
src/myfasthtml/assets/vis-network.min.js
vendored
34
src/myfasthtml/assets/vis-network.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -41,7 +41,7 @@ Note: `python-jose` may already be installed if you have FastAPI.
|
||||
### 1. Update API configuration in `auth/utils.py`
|
||||
|
||||
```python
|
||||
API_BASE_URL = "http://localhost:5003" # Your FastAPI backend URL
|
||||
API_BASE_URL = "http://localhost:5001" # Your FastAPI backend URL
|
||||
JWT_SECRET = "jwt-secret-to-change" # Must match your FastAPI secret
|
||||
```
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from ..auth.utils import (
|
||||
logout_user,
|
||||
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):
|
||||
@@ -182,9 +181,6 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", b
|
||||
if refresh_token:
|
||||
logout_user(refresh_token)
|
||||
|
||||
# release memory
|
||||
InstancesManager.clear_session(session)
|
||||
|
||||
# Clear session
|
||||
session.clear()
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from fasthtml.common import RedirectResponse, Beforeware
|
||||
from jose import jwt, JWTError
|
||||
|
||||
# Configuration
|
||||
API_BASE_URL = "http://localhost:5003" # Base URL for FastAPI backend
|
||||
API_BASE_URL = "http://localhost:5001" # Base URL for FastAPI backend
|
||||
JWT_SECRET = "jwt-secret-to-change" # Must match FastAPI secret
|
||||
JWT_ALGORITHM = "HS256"
|
||||
TOKEN_REFRESH_THRESHOLD_MINUTES = 5 # Refresh token if expires in less than 5 minutes
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import Ids
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
|
||||
|
||||
class BoundariesState:
|
||||
def __init__(self):
|
||||
# persisted in DB
|
||||
self.width: int = 0
|
||||
self.height: int = 0
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def update_boundaries(self):
|
||||
return Command(f"{self._prefix}UpdateBoundaries",
|
||||
"Update component boundaries",
|
||||
self._owner.update_boundaries).htmx(target=f"{self._owner.get_id()}")
|
||||
|
||||
|
||||
class Boundaries(SingleInstance):
|
||||
"""
|
||||
Ask the boundaries of the given control
|
||||
Keep the boundaries updated
|
||||
"""
|
||||
|
||||
def __init__(self, owner, container_id: str = None, on_resize=None, _id=None):
|
||||
super().__init__(owner, _id=_id)
|
||||
self._owner = owner
|
||||
self._container_id = container_id or owner.get_id()
|
||||
self._on_resize = on_resize
|
||||
self._commands = Commands(self)
|
||||
self._state = BoundariesState()
|
||||
self._get_boundaries_command = self._commands.update_boundaries()
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
return self._state.width
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
return self._state.height
|
||||
|
||||
def update_boundaries(self, width: int, height: int):
|
||||
"""
|
||||
Update the component boundaries.
|
||||
|
||||
Args:
|
||||
width: Available width in pixels
|
||||
height: Available height in pixels
|
||||
"""
|
||||
self._state.width = width
|
||||
self._state.height = height
|
||||
return self._on_resize() if self._on_resize else self._owner
|
||||
|
||||
def render(self):
|
||||
return Script(f"initBoundaries('{self._container_id}', '{self._get_boundaries_command.url}');")
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,39 +0,0 @@
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.commands import CommandsManager
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.network_utils import from_parent_child_list
|
||||
|
||||
|
||||
class CommandsDebugger(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
def render(self):
|
||||
commands = self._get_commands()
|
||||
nodes, edges = from_parent_child_list(commands,
|
||||
id_getter=lambda x: str(x.id),
|
||||
label_getter=lambda x: x.name,
|
||||
parent_getter=lambda x: str(self.get_command_parent(x))
|
||||
)
|
||||
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges)
|
||||
return vis_network
|
||||
|
||||
@staticmethod
|
||||
def get_command_parent(command):
|
||||
if (ft := command.get_ft()) is None:
|
||||
return None
|
||||
if hasattr(ft, "get_id") and callable(ft.get_id):
|
||||
return ft.get_id()
|
||||
if hasattr(ft, "get_prefix") and callable(ft.get_prefix):
|
||||
return ft.get_prefix()
|
||||
if hasattr(ft, "attrs"):
|
||||
return ft.attrs.get("id", None)
|
||||
|
||||
return None
|
||||
|
||||
def _get_commands(self):
|
||||
return list(CommandsManager.commands.values())
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,93 +0,0 @@
|
||||
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):
|
||||
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';
|
||||
# }
|
||||
# }
|
||||
# });
|
||||
@@ -1,98 +0,0 @@
|
||||
import logging
|
||||
from io import BytesIO
|
||||
|
||||
import pandas as pd
|
||||
from fastapi import UploadFile
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import Ids, mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
logger = logging.getLogger("FileUpload")
|
||||
|
||||
|
||||
class FileUploadState(DbObject):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
with self.initializing():
|
||||
# persisted in DB
|
||||
|
||||
# must not be persisted in DB (prefix ns_ = no_saving_)
|
||||
self.ns_file_name: str | None = None
|
||||
self.ns_sheets_names: list | None = None
|
||||
self.ns_selected_sheet_name: str | None = None
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
|
||||
def upload_file(self):
|
||||
return Command("UploadFile", "Upload file", self._owner.upload_file).htmx(target=f"#sn_{self._id}")
|
||||
|
||||
|
||||
class FileUpload(MultipleInstance):
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.commands = Commands(self)
|
||||
self._state = FileUploadState(self)
|
||||
|
||||
def upload_file(self, file: UploadFile):
|
||||
logger.debug(f"upload_file: {file=}")
|
||||
if file:
|
||||
file_content = file.file.read()
|
||||
self._state.ns_sheets_names = self.get_sheets_names(file_content)
|
||||
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()
|
||||
|
||||
def mk_sheet_selector(self):
|
||||
options = [Option("Choose a file...", selected=True, disabled=True)] if self._state.ns_sheets_names is None else \
|
||||
[Option(
|
||||
name,
|
||||
selected=True if name == self._state.ns_selected_sheet_name else None,
|
||||
) for name in self._state.ns_sheets_names]
|
||||
|
||||
return Select(
|
||||
*options,
|
||||
name="sheet_name",
|
||||
id=f"sn_{self._id}", # sn stands for 'sheet name'
|
||||
cls="select select-bordered select-sm w-full ml-2"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_sheets_names(file_content):
|
||||
try:
|
||||
excel_file = pd.ExcelFile(BytesIO(file_content))
|
||||
sheet_names = excel_file.sheet_names
|
||||
except Exception as ex:
|
||||
logger.error(f"get_sheets_names: {ex=}")
|
||||
sheet_names = []
|
||||
|
||||
return sheet_names
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
Div(
|
||||
mk.mk(Input(type='file',
|
||||
name='file',
|
||||
id=f"fi_{self._id}", # fn stands for 'file name'
|
||||
value=self._state.ns_file_name,
|
||||
hx_preserve="true",
|
||||
hx_encoding='multipart/form-data',
|
||||
cls="file-input file-input-bordered file-input-sm w-full",
|
||||
),
|
||||
command=self.commands.upload_file()
|
||||
),
|
||||
self.mk_sheet_selector(),
|
||||
cls="flex"
|
||||
),
|
||||
mk.dialog_buttons(),
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,38 +0,0 @@
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.network_utils import from_parent_child_list
|
||||
|
||||
|
||||
class InstancesDebugger(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||
return self._panel.set_main(vis_network)
|
||||
|
||||
def _get_nodes_and_edges(self):
|
||||
instances = self._get_instances()
|
||||
nodes, edges = from_parent_child_list(
|
||||
instances,
|
||||
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):
|
||||
return list(InstancesManager.instances.values())
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,23 +0,0 @@
|
||||
import json
|
||||
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Keyboard(MultipleInstance):
|
||||
def __init__(self, parent, combinations=None, _id=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_keyboard_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -10,13 +10,11 @@ from typing import Literal
|
||||
from fasthtml.common import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.Boundaries import Boundaries
|
||||
from myfasthtml.controls.UserProfile import UserProfile
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.controls.helpers import mk, Ids
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.utils import get_id
|
||||
from myfasthtml.core.instances import InstancesManager, SingleInstance
|
||||
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
|
||||
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
|
||||
|
||||
@@ -25,7 +23,7 @@ logger = logging.getLogger("LayoutControl")
|
||||
|
||||
class LayoutState(DbObject):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
super().__init__(owner.get_session(), owner.get_id())
|
||||
with self.initializing():
|
||||
self.left_drawer_open: bool = True
|
||||
self.right_drawer_open: bool = True
|
||||
@@ -35,7 +33,7 @@ class LayoutState(DbObject):
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
|
||||
return Command("ToggleDrawer", "Toggle main layout drawer", self._owner.toggle_drawer, side)
|
||||
|
||||
def update_drawer_width(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
@@ -66,41 +64,19 @@ class Layout(SingleInstance):
|
||||
right_drawer (bool): Whether to include a right drawer
|
||||
"""
|
||||
|
||||
class Content:
|
||||
def __init__(self, owner):
|
||||
class DrawerContent:
|
||||
def __init__(self, owner, side: Literal["left", "right"]):
|
||||
self._owner = owner
|
||||
self._content = {}
|
||||
self._groups = []
|
||||
self._ids = set()
|
||||
self.side = side
|
||||
self._content = []
|
||||
|
||||
def add_group(self, group, group_ft=None):
|
||||
group_ft = group_ft or Div(group, cls="mf-layout-group")
|
||||
if not group:
|
||||
group_ft = None
|
||||
self._groups.append((group, group_ft))
|
||||
self._content[group] = []
|
||||
|
||||
def add(self, content, group=None):
|
||||
content_id = get_id(content)
|
||||
if content_id in self._ids:
|
||||
return
|
||||
|
||||
if group not in self._content:
|
||||
self.add_group(group)
|
||||
self._content[group] = []
|
||||
|
||||
self._content[group].append(content)
|
||||
|
||||
if content_id is not None:
|
||||
self._ids.add(content_id)
|
||||
def append(self, content):
|
||||
self._content.append(content)
|
||||
|
||||
def get_content(self):
|
||||
return self._content
|
||||
|
||||
def get_groups(self):
|
||||
return self._groups
|
||||
|
||||
def __init__(self, parent, app_name, _id=None):
|
||||
def __init__(self, session, app_name):
|
||||
"""
|
||||
Initialize the Layout component.
|
||||
|
||||
@@ -109,21 +85,17 @@ class Layout(SingleInstance):
|
||||
left_drawer (bool): Enable left drawer. Default is True.
|
||||
right_drawer (bool): Enable right drawer. Default is True.
|
||||
"""
|
||||
super().__init__(parent, _id=_id)
|
||||
super().__init__(session, Ids.Layout)
|
||||
self.app_name = app_name
|
||||
|
||||
# Content storage
|
||||
self._header_content = None
|
||||
self._footer_content = None
|
||||
self._main_content = None
|
||||
self._state = LayoutState(self)
|
||||
self._boundaries = Boundaries(self)
|
||||
self.commands = Commands(self)
|
||||
self.left_drawer = self.Content(self)
|
||||
self.right_drawer = self.Content(self)
|
||||
self.header_left = self.Content(self)
|
||||
self.header_right = self.Content(self)
|
||||
self.footer_left = self.Content(self)
|
||||
self.footer_right = self.Content(self)
|
||||
self._footer_content = None
|
||||
self.left_drawer = self.DrawerContent(self, "left")
|
||||
self.right_drawer = self.DrawerContent(self, "right")
|
||||
|
||||
def set_footer(self, content):
|
||||
"""
|
||||
@@ -142,7 +114,6 @@ class Layout(SingleInstance):
|
||||
content: FastHTML component(s) or content for the main area
|
||||
"""
|
||||
self._main_content = content
|
||||
return self
|
||||
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
|
||||
@@ -188,16 +159,8 @@ class Layout(SingleInstance):
|
||||
Header: FastHTML Header component
|
||||
"""
|
||||
return Header(
|
||||
Div( # left
|
||||
self._mk_left_drawer_icon(),
|
||||
*self.header_left.get_content(),
|
||||
cls="flex gap-1"
|
||||
),
|
||||
Div( # right
|
||||
*self.header_right.get_content()[None],
|
||||
UserProfile(self),
|
||||
cls="flex gap-1"
|
||||
),
|
||||
self._mk_left_drawer_icon(),
|
||||
InstancesManager.get(self._session, Ids.UserProfile, UserProfile),
|
||||
cls="mf-layout-header"
|
||||
)
|
||||
|
||||
@@ -235,21 +198,14 @@ class Layout(SingleInstance):
|
||||
Div: FastHTML Div component for left drawer
|
||||
"""
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-left",
|
||||
cls="mf-layout-resizer mf-layout-resizer-right",
|
||||
data_command_id=self.commands.update_drawer_width("left").id,
|
||||
data_side="left"
|
||||
)
|
||||
|
||||
# Wrap content in scrollable container
|
||||
content_wrapper = Div(
|
||||
*[
|
||||
(
|
||||
Div(cls="divider") if index > 0 else None,
|
||||
group_ft,
|
||||
*[item for item in self.left_drawer.get_content()[group_name]]
|
||||
)
|
||||
for index, (group_name, group_ft) in enumerate(self.left_drawer.get_groups())
|
||||
],
|
||||
*self.left_drawer.get_content(),
|
||||
cls="mf-layout-drawer-content"
|
||||
)
|
||||
|
||||
@@ -268,9 +224,8 @@ class Layout(SingleInstance):
|
||||
Returns:
|
||||
Div: FastHTML Div component for right drawer
|
||||
"""
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-right",
|
||||
cls="mf-layout-resizer mf-layout-resizer-left",
|
||||
data_command_id=self.commands.update_drawer_width("right").id,
|
||||
data_side="right"
|
||||
)
|
||||
@@ -314,7 +269,7 @@ class Layout(SingleInstance):
|
||||
self._mk_main(),
|
||||
self._mk_right_drawer(),
|
||||
self._mk_footer(),
|
||||
Script(f"initResizer('{self._id}');"),
|
||||
Script(f"initLayoutResizer('{self._id}');"),
|
||||
id=self._id,
|
||||
cls="mf-layout",
|
||||
)
|
||||
@@ -326,4 +281,4 @@ class Layout(SingleInstance):
|
||||
Returns:
|
||||
Div: The rendered layout
|
||||
"""
|
||||
return self.render()
|
||||
return self.render()
|
||||
@@ -1,23 +0,0 @@
|
||||
import json
|
||||
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Mouse(MultipleInstance):
|
||||
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()
|
||||
@@ -1,102 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from fasthtml.components import Div
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
@dataclass
|
||||
class PanelConf:
|
||||
left: bool = False
|
||||
right: bool = True
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_side(self, side: Literal["left", "right"]):
|
||||
return Command("TogglePanelSide", f"Toggle {side} side panel", self._owner.toggle_side, side)
|
||||
|
||||
def update_side_width(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
Create a command to update panel's side width.
|
||||
|
||||
Args:
|
||||
side: Which panel's side to update ("left" or "right")
|
||||
|
||||
Returns:
|
||||
Command: Command object for updating panel's side width
|
||||
"""
|
||||
return Command(
|
||||
f"UpdatePanelSideWidth_{side}",
|
||||
f"Update {side} side panel width",
|
||||
self._owner.update_side_width,
|
||||
side
|
||||
)
|
||||
|
||||
|
||||
class Panel(MultipleInstance):
|
||||
def __init__(self, parent, conf=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or PanelConf()
|
||||
self.commands = Commands(self)
|
||||
self._main = None
|
||||
self._right = None
|
||||
self._left = None
|
||||
|
||||
def update_side_width(self, side, width):
|
||||
pass
|
||||
|
||||
def toggle_side(self, side):
|
||||
pass
|
||||
|
||||
def set_main(self, main):
|
||||
self._main = main
|
||||
return self
|
||||
|
||||
def set_right(self, right):
|
||||
self._right = right
|
||||
return self
|
||||
|
||||
def set_left(self, left):
|
||||
self._left = left
|
||||
return self
|
||||
|
||||
def _mk_right(self):
|
||||
if not self.conf.right:
|
||||
return None
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-right",
|
||||
data_command_id=self.commands.update_side_width("right").id,
|
||||
data_side="right"
|
||||
)
|
||||
|
||||
return Div(resizer, self._right, cls="mf-panel-right")
|
||||
|
||||
def _mk_left(self):
|
||||
if not self.conf.left:
|
||||
return None
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-left",
|
||||
data_command_id=self.commands.update_side_width("left").id,
|
||||
data_side="left"
|
||||
)
|
||||
|
||||
return Div(self._left, resizer, cls="mf-panel-left")
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_left(),
|
||||
Div(self._main, cls="mf-panel-main"),
|
||||
self._mk_right(),
|
||||
Script(f"initResizer('{self._id}');"),
|
||||
cls="mf-panel",
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,90 +0,0 @@
|
||||
import logging
|
||||
from typing import Callable, Any
|
||||
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
||||
from myfasthtml.core.matching_utils import subsequence_matching, fuzzy_matching
|
||||
|
||||
logger = logging.getLogger("Search")
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def search(self):
|
||||
return (Command("Search", f"Search {self._owner.items_names}", self._owner.on_search).
|
||||
htmx(target=f"#{self._owner.get_id()}-results",
|
||||
trigger="keyup changed delay:300ms",
|
||||
swap="innerHTML"))
|
||||
|
||||
|
||||
class Search(MultipleInstance):
|
||||
def __init__(self,
|
||||
parent: BaseInstance,
|
||||
_id=None,
|
||||
items_names=None, # what is the name of the items to filter
|
||||
items=None, # first set of items to filter
|
||||
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
|
||||
template: Callable[[Any], Any] = None): # once filtered, what to render ?
|
||||
"""
|
||||
Represents a component for managing and filtering a list of items based on specific criteria.
|
||||
|
||||
This class initializes with a session, an optional identifier, a list of item names,
|
||||
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.
|
||||
|
||||
:param _id: Optional identifier for the component.
|
||||
: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
|
||||
function that returns the item as is.
|
||||
:param template: Callable function to render the filtered items. Defaults to a Div rendering function.
|
||||
"""
|
||||
super().__init__(parent, _id=_id)
|
||||
self.items_names = items_names or ''
|
||||
self.items = items or []
|
||||
self.filtered = self.items.copy()
|
||||
self.get_attr = get_attr or (lambda x: x)
|
||||
self.template = template or Div
|
||||
self.commands = Commands(self)
|
||||
|
||||
def set_items(self, items):
|
||||
self.items = items
|
||||
self.filtered = self.items.copy()
|
||||
return self
|
||||
|
||||
def on_search(self, query):
|
||||
logger.debug(f"on_search {query=}")
|
||||
self.search(query)
|
||||
return tuple(self._mk_search_results())
|
||||
|
||||
def search(self, query):
|
||||
logger.debug(f"search {query=}")
|
||||
if query is None or query.strip() == "":
|
||||
self.filtered = self.items.copy()
|
||||
|
||||
else:
|
||||
res_seq = subsequence_matching(query, self.items, get_attr=self.get_attr)
|
||||
res_fuzzy = fuzzy_matching(query, self.items, get_attr=self.get_attr)
|
||||
self.filtered = res_seq + res_fuzzy
|
||||
|
||||
return self.filtered
|
||||
|
||||
def _mk_search_results(self):
|
||||
return [self.template(item) for item in self.filtered]
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
mk.mk(Input(name="query", id=f"{self._id}-search", type="text", placeholder="Search...", cls="input input-xs"),
|
||||
command=self.commands.search()),
|
||||
Div(
|
||||
*self._mk_search_results(),
|
||||
id=f"{self._id}-results",
|
||||
cls="mf-search-results",
|
||||
),
|
||||
id=f"{self._id}",
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -1,50 +1,29 @@
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import Div, Span
|
||||
from fasthtml.xtend import Script
|
||||
from fasthtml.common import Div, Button, Span
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.Search import Search
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.controls.helpers import Ids, mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance, InstancesManager
|
||||
from myfasthtml.icons.fluent_p1 import tabs24_regular
|
||||
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular, tab_add24_regular
|
||||
|
||||
logger = logging.getLogger("TabsManager")
|
||||
|
||||
vis_nodes = [
|
||||
{"id": 1, "label": "Node 1"},
|
||||
{"id": 2, "label": "Node 2"},
|
||||
{"id": 3, "label": "Node 3"},
|
||||
{"id": 4, "label": "Node 4"},
|
||||
{"id": 5, "label": "Node 5"}
|
||||
]
|
||||
|
||||
vis_edges = [
|
||||
{"from": 1, "to": 3},
|
||||
{"from": 1, "to": 2},
|
||||
{"from": 2, "to": 4},
|
||||
{"from": 2, "to": 5},
|
||||
{"from": 3, "to": 3}
|
||||
]
|
||||
from myfasthtml.core.instances import MultipleInstance, BaseInstance
|
||||
from myfasthtml.icons.fluent_p3 import dismiss_circle16_regular
|
||||
|
||||
|
||||
@dataclass
|
||||
class Boundaries:
|
||||
"""Store component boundaries"""
|
||||
width: int = 1020
|
||||
height: int = 782
|
||||
|
||||
# Structure de tabs:
|
||||
# {
|
||||
# "tab-uuid-1": {
|
||||
# "label": "Users",
|
||||
# "component_type": "UsersPanel",
|
||||
# "component_id": "UsersPanel-abc123",
|
||||
# },
|
||||
# "tab-uuid-2": { ... }
|
||||
# }
|
||||
|
||||
class TabsManagerState(DbObject):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
super().__init__(owner.get_session(), owner.get_id())
|
||||
with self.initializing():
|
||||
# persisted in DB
|
||||
self.tabs: dict[str, Any] = {}
|
||||
@@ -57,75 +36,23 @@ class TabsManagerState(DbObject):
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def show_tab(self, tab_id):
|
||||
return Command(f"{self._prefix}ShowTab",
|
||||
return Command(f"{self._prefix}SowTab",
|
||||
"Activate or show a specific tab",
|
||||
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}-controller", swap="outerHTML")
|
||||
|
||||
def close_tab(self, tab_id):
|
||||
return Command(f"{self._prefix}CloseTab",
|
||||
"Close a specific tab",
|
||||
self._owner.close_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
|
||||
def add_tab(self, label: str, component: Any, auto_increment=False):
|
||||
return (Command(f"{self._prefix}AddTab",
|
||||
"Add a new tab",
|
||||
self._owner.on_new_tab, label, component, auto_increment).
|
||||
htmx(target=f"#{self._id}-controller"))
|
||||
self._owner.show_tab, tab_id).htmx(target=f"#{self._id}", swap="outerHTML")
|
||||
|
||||
|
||||
class TabsManager(MultipleInstance):
|
||||
_tab_count = 0
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
def __init__(self, session, _id=None):
|
||||
super().__init__(session, Ids.TabsManager, _id=_id)
|
||||
self._state = TabsManagerState(self)
|
||||
self.commands = Commands(self)
|
||||
self._boundaries = Boundaries()
|
||||
self._search = Search(self,
|
||||
items=self._get_tab_list(),
|
||||
get_attr=lambda x: x["label"],
|
||||
template=self._mk_tab_button,
|
||||
_id="-search")
|
||||
logger.debug(f"TabsManager created with id: {self._id}")
|
||||
logger.debug(f" tabs : {self._get_ordered_tabs()}")
|
||||
logger.debug(f" active tab : {self._state.active_tab}")
|
||||
|
||||
def get_state(self):
|
||||
return self._state
|
||||
|
||||
def _get_ordered_tabs(self):
|
||||
return {tab_id: self._state.tabs.get(tab_id, None) for tab_id in self._state.tabs_order}
|
||||
|
||||
def _get_tab_content(self, tab_id):
|
||||
if tab_id not in self._state.tabs:
|
||||
return None
|
||||
tab_config = self._state.tabs[tab_id]
|
||||
if tab_config["component_type"] is None:
|
||||
return None
|
||||
return InstancesManager.get(self._session, tab_config["component_id"])
|
||||
|
||||
@staticmethod
|
||||
def _get_tab_count():
|
||||
res = TabsManager._tab_count
|
||||
TabsManager._tab_count += 1
|
||||
return res
|
||||
|
||||
def on_new_tab(self, label: str, component: Any, auto_increment=False):
|
||||
logger.debug(f"on_new_tab {label=}, {component=}, {auto_increment=}")
|
||||
if auto_increment:
|
||||
label = f"{label}_{self._get_tab_count()}"
|
||||
component = component or VisNetwork(self, nodes=vis_nodes, edges=vis_edges)
|
||||
|
||||
tab_id = self._tab_already_exists(label, component)
|
||||
if tab_id:
|
||||
return self.show_tab(tab_id)
|
||||
|
||||
tab_id = self.add_tab(label, component)
|
||||
return (
|
||||
self._mk_tabs_controller(),
|
||||
self._wrap_tab_content(self._mk_tab_content(tab_id, component)),
|
||||
self._mk_tabs_header_wrapper(True),
|
||||
)
|
||||
def on_new_tab(self, label: str, component: Any):
|
||||
self.add_tab(label, component)
|
||||
return self
|
||||
|
||||
def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
|
||||
"""
|
||||
@@ -139,18 +66,24 @@ class TabsManager(MultipleInstance):
|
||||
Returns:
|
||||
tab_id: The UUID of the tab (new or existing)
|
||||
"""
|
||||
logger.debug(f"add_tab {label=}, component={component}, activate={activate}")
|
||||
# copy the state to avoid multiple database call
|
||||
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_type = type(component).__name__
|
||||
component_id = component.get_id()
|
||||
|
||||
# Check if a tab with the same component_type, component_id AND label already exists
|
||||
existing_tab_id = self._tab_already_exists(label, component)
|
||||
existing_tab_id = None
|
||||
if component_id is not None:
|
||||
for tab_id, tab_data in state.tabs.items():
|
||||
if (tab_data.get('component_type') == component_type and
|
||||
tab_data.get('component_id') == component_id and
|
||||
tab_data.get('label') == label):
|
||||
existing_tab_id = tab_id
|
||||
break
|
||||
|
||||
if existing_tab_id:
|
||||
# Update existing tab (only the component instance in memory)
|
||||
@@ -162,7 +95,6 @@ class TabsManager(MultipleInstance):
|
||||
|
||||
# Add tab metadata to state
|
||||
state.tabs[tab_id] = {
|
||||
'id': tab_id,
|
||||
'label': label,
|
||||
'component_type': component_type,
|
||||
'component_id': component_id
|
||||
@@ -180,224 +112,87 @@ class TabsManager(MultipleInstance):
|
||||
|
||||
# finally, update the state
|
||||
self._state.update(state)
|
||||
self._search.set_items(self._get_tab_list())
|
||||
|
||||
return tab_id
|
||||
|
||||
def show_tab(self, tab_id):
|
||||
logger.debug(f"show_tab {tab_id=}")
|
||||
if tab_id not in self._state.tabs:
|
||||
logger.debug(f" Tab not found.")
|
||||
return None
|
||||
|
||||
logger.debug(f" Tab label is: {self._state.tabs[tab_id]['label']}")
|
||||
self._state.active_tab = tab_id
|
||||
|
||||
if tab_id not in self._state._tabs_content:
|
||||
logger.debug(f" Content does not exist. Creating it.")
|
||||
content = self._get_tab_content(tab_id)
|
||||
tab_content = self._mk_tab_content(tab_id, content)
|
||||
self._state._tabs_content[tab_id] = tab_content
|
||||
return self._mk_tabs_controller(), self._wrap_tab_content(tab_content)
|
||||
else:
|
||||
logger.debug(f" Content already exists. Just switch.")
|
||||
return self._mk_tabs_controller()
|
||||
|
||||
def close_tab(self, tab_id: str):
|
||||
"""
|
||||
Close a tab and remove it from the tabs manager.
|
||||
|
||||
Args:
|
||||
tab_id: ID of the tab to close
|
||||
|
||||
Returns:
|
||||
Self for chaining
|
||||
"""
|
||||
logger.debug(f"close_tab {tab_id=}")
|
||||
if tab_id not in self._state.tabs:
|
||||
return self
|
||||
|
||||
# Copy state
|
||||
state = self._state.copy()
|
||||
|
||||
# Remove from tabs and order
|
||||
del state.tabs[tab_id]
|
||||
state.tabs_order.remove(tab_id)
|
||||
|
||||
# Remove from content
|
||||
if tab_id in state._tabs_content:
|
||||
del state._tabs_content[tab_id]
|
||||
|
||||
# If closing active tab, activate another one
|
||||
if state.active_tab == tab_id:
|
||||
if state.tabs_order:
|
||||
# Activate the first remaining tab
|
||||
state.active_tab = state.tabs_order[0]
|
||||
else:
|
||||
state.active_tab = None
|
||||
|
||||
# Update state
|
||||
self._state.update(state)
|
||||
self._search.set_items(self._get_tab_list())
|
||||
|
||||
return self
|
||||
|
||||
def add_tab_btn(self):
|
||||
return mk.icon(tab_add24_regular,
|
||||
id=f"{self._id}-add-tab-btn",
|
||||
cls="mf-tab-btn",
|
||||
command=self.commands.add_tab(f"Untitled",
|
||||
None,
|
||||
True))
|
||||
|
||||
def _mk_tabs_controller(self):
|
||||
return Div(
|
||||
Div(id=f"{self._id}-controller", data_active_tab=f"{self._state.active_tab}"),
|
||||
Script(f'updateTabs("{self._id}-controller");'),
|
||||
)
|
||||
|
||||
def _mk_tabs_header_wrapper(self, oob=False):
|
||||
# Create visible tab buttons
|
||||
visible_tab_buttons = [
|
||||
self._mk_tab_button(self._state.tabs[tab_id])
|
||||
for tab_id in self._state.tabs_order
|
||||
if tab_id in self._state.tabs
|
||||
]
|
||||
|
||||
header_content = [*visible_tab_buttons]
|
||||
|
||||
return Div(
|
||||
Div(*header_content, cls="mf-tabs-header", id=f"{self._id}-header"),
|
||||
self._mk_show_tabs_menu(),
|
||||
id=f"{self._id}-header-wrapper",
|
||||
cls="mf-tabs-header-wrapper",
|
||||
hx_swap_oob="true" if oob else None
|
||||
)
|
||||
|
||||
def _mk_tab_button(self, tab_data: dict, in_dropdown: bool = False):
|
||||
def _mk_tab_button(self, tab_id: str, tab_data: dict):
|
||||
"""
|
||||
Create a single tab button with its label and close button.
|
||||
|
||||
Args:
|
||||
tab_id: Unique identifier for the tab
|
||||
tab_data: Dictionary containing tab information (label, component_type, etc.)
|
||||
in_dropdown: Whether this tab is rendered in the dropdown menu
|
||||
|
||||
Returns:
|
||||
Button element representing the tab
|
||||
"""
|
||||
tab_id = tab_data["id"]
|
||||
is_active = tab_id == self._state.active_tab
|
||||
|
||||
close_btn = mk.mk(
|
||||
return Button(
|
||||
mk.mk(Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"), command=self.commands.show_tab(tab_id)),
|
||||
Span(dismiss_circle16_regular, cls="mf-tab-close-btn"),
|
||||
command=self.commands.close_tab(tab_id)
|
||||
)
|
||||
|
||||
tab_label = mk.mk(
|
||||
Span(tab_data.get("label", "Untitled"), cls="mf-tab-label"),
|
||||
command=self.commands.show_tab(tab_id)
|
||||
)
|
||||
|
||||
extra_cls = "mf-tab-in-dropdown" if in_dropdown else ""
|
||||
|
||||
return Div(
|
||||
tab_label,
|
||||
close_btn,
|
||||
cls=f"mf-tab-button {extra_cls} {'mf-tab-active' if is_active else ''}",
|
||||
cls=f"mf-tab-button {'mf-tab-active' if is_active else ''}",
|
||||
data_tab_id=tab_id,
|
||||
data_manager_id=self._id
|
||||
)
|
||||
|
||||
def _mk_tab_content_wrapper(self):
|
||||
def _mk_tabs_header(self):
|
||||
"""
|
||||
Create the tabs header containing all tab buttons.
|
||||
|
||||
Returns:
|
||||
Div element containing all tab buttons
|
||||
"""
|
||||
tab_buttons = [
|
||||
self._mk_tab_button(tab_id, self._state.tabs[tab_id])
|
||||
for tab_id in self._state.tabs_order
|
||||
if tab_id in self._state.tabs
|
||||
]
|
||||
|
||||
return Div(
|
||||
*tab_buttons,
|
||||
cls="mf-tabs-header",
|
||||
id=f"{self._id}-header"
|
||||
)
|
||||
|
||||
def _mk_tab_content(self):
|
||||
"""
|
||||
Create the active tab content area.
|
||||
|
||||
Returns:
|
||||
Div element containing the active tab content or empty container
|
||||
"""
|
||||
content = None
|
||||
|
||||
if self._state.active_tab:
|
||||
active_tab = self._state.active_tab
|
||||
if active_tab in self._state._tabs_content:
|
||||
tab_content = self._state._tabs_content[active_tab]
|
||||
else:
|
||||
content = self._get_tab_content(active_tab)
|
||||
tab_content = self._mk_tab_content(active_tab, content)
|
||||
self._state._tabs_content[active_tab] = tab_content
|
||||
else:
|
||||
tab_content = self._mk_tab_content(None, None)
|
||||
if self._state.active_tab and self._state.active_tab in self._state._tabs_content:
|
||||
component = self._state._tabs_content[self._state.active_tab]
|
||||
content = component
|
||||
|
||||
return Div(
|
||||
tab_content,
|
||||
cls="mf-tab-content-wrapper",
|
||||
id=f"{self._id}-content-wrapper",
|
||||
content if content else Div("No active tab", cls="mf-empty-content"),
|
||||
cls="mf-tab-content",
|
||||
id=f"{self._id}-content"
|
||||
)
|
||||
|
||||
def _mk_tab_content(self, tab_id: str, content):
|
||||
is_active = tab_id == self._state.active_tab
|
||||
return Div(
|
||||
content if content else Div("No Content", cls="mf-empty-content"),
|
||||
cls=f"mf-tab-content {'hidden' if not is_active else ''}", # ← ici
|
||||
id=f"{self._id}-{tab_id}-content",
|
||||
)
|
||||
|
||||
def _mk_show_tabs_menu(self):
|
||||
return Div(
|
||||
mk.icon(tabs24_regular,
|
||||
size="32",
|
||||
tabindex="0",
|
||||
role="button",
|
||||
cls="btn btn-xs"),
|
||||
Div(
|
||||
self._search,
|
||||
tabindex="-1",
|
||||
cls="dropdown-content menu w-52 rounded-box bg-base-300 shadow-xl"
|
||||
),
|
||||
cls="dropdown dropdown-end"
|
||||
)
|
||||
|
||||
def _wrap_tab_content(self, tab_content):
|
||||
return Div(
|
||||
tab_content,
|
||||
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper",
|
||||
)
|
||||
|
||||
def _tab_already_exists(self, label, component):
|
||||
if not isinstance(component, BaseInstance):
|
||||
return None
|
||||
|
||||
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
|
||||
component_id = component.get_id()
|
||||
|
||||
if component_id is not None:
|
||||
for tab_id, tab_data in self._state.tabs.items():
|
||||
if (tab_data.get('component_type') == component_type and
|
||||
tab_data.get('component_id') == component_id and
|
||||
tab_data.get('label') == label):
|
||||
return tab_id
|
||||
|
||||
return None
|
||||
|
||||
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]
|
||||
|
||||
def update_boundaries(self):
|
||||
return Script(f"updateBoundaries('{self._id}');")
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the complete TabsManager component.
|
||||
|
||||
Returns:
|
||||
Div element containing tabs header, content area, and resize script
|
||||
Div element containing tabs header and content area
|
||||
"""
|
||||
return Div(
|
||||
self._mk_tabs_controller(),
|
||||
self._mk_tabs_header_wrapper(),
|
||||
self._mk_tab_content_wrapper(),
|
||||
self._mk_tabs_header(),
|
||||
self._mk_tab_content(),
|
||||
cls="mf-tabs-manager",
|
||||
id=self._id,
|
||||
id=self._id
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
|
||||
@@ -1,400 +0,0 @@
|
||||
"""
|
||||
TreeView component for hierarchical data visualization with inline editing.
|
||||
|
||||
This component provides an interactive tree structure with expand/collapse,
|
||||
selection, and inline editing capabilities.
|
||||
"""
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from fasthtml.components import Div, Input, Span
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, edit20_regular
|
||||
from myfasthtml.icons.fluent_p2 import chevron_down20_regular, add_circle20_regular, delete20_regular
|
||||
|
||||
|
||||
@dataclass
|
||||
class TreeNode:
|
||||
"""
|
||||
Represents a node in the tree structure.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier (auto-generated UUID if not provided)
|
||||
label: Display text for the node
|
||||
type: Node type for icon mapping
|
||||
parent: ID of parent node (None for root)
|
||||
children: List of child node IDs
|
||||
"""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
label: str = ""
|
||||
type: str = "default"
|
||||
parent: Optional[str] = None
|
||||
children: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class TreeViewState(DbObject):
|
||||
"""
|
||||
Persistent state for TreeView component.
|
||||
|
||||
Attributes:
|
||||
items: Dictionary mapping node IDs to TreeNode instances
|
||||
opened: List of expanded node IDs
|
||||
selected: Currently selected node ID
|
||||
editing: Node ID currently being edited (None if not editing)
|
||||
icon_config: Mapping of node types to icon identifiers
|
||||
"""
|
||||
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
with self.initializing():
|
||||
self.items: dict[str, TreeNode] = {}
|
||||
self.opened: list[str] = []
|
||||
self.selected: Optional[str] = None
|
||||
self.editing: Optional[str] = None
|
||||
self.icon_config: dict[str, str] = {}
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
"""Command handlers for TreeView actions."""
|
||||
|
||||
def toggle_node(self, node_id: str):
|
||||
"""Create command to expand/collapse a node."""
|
||||
return Command(
|
||||
"ToggleNode",
|
||||
f"Toggle node {node_id}",
|
||||
self._owner._toggle_node,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def add_child(self, parent_id: str):
|
||||
"""Create command to add a child node."""
|
||||
return Command(
|
||||
"AddChild",
|
||||
f"Add child to {parent_id}",
|
||||
self._owner._add_child,
|
||||
parent_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def add_sibling(self, node_id: str):
|
||||
"""Create command to add a sibling node."""
|
||||
return Command(
|
||||
"AddSibling",
|
||||
f"Add sibling to {node_id}",
|
||||
self._owner._add_sibling,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def start_rename(self, node_id: str):
|
||||
"""Create command to start renaming a node."""
|
||||
return Command(
|
||||
"StartRename",
|
||||
f"Start renaming {node_id}",
|
||||
self._owner._start_rename,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def save_rename(self, node_id: str):
|
||||
"""Create command to save renamed node."""
|
||||
return Command(
|
||||
"SaveRename",
|
||||
f"Save rename for {node_id}",
|
||||
self._owner._save_rename,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def cancel_rename(self):
|
||||
"""Create command to cancel renaming."""
|
||||
return Command(
|
||||
"CancelRename",
|
||||
"Cancel rename",
|
||||
self._owner._cancel_rename
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def delete_node(self, node_id: str):
|
||||
"""Create command to delete a node."""
|
||||
return Command(
|
||||
"DeleteNode",
|
||||
f"Delete node {node_id}",
|
||||
self._owner._delete_node,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
def select_node(self, node_id: str):
|
||||
"""Create command to select a node."""
|
||||
return Command(
|
||||
"SelectNode",
|
||||
f"Select node {node_id}",
|
||||
self._owner._select_node,
|
||||
node_id
|
||||
).htmx(target=f"#{self._owner.get_id()}")
|
||||
|
||||
|
||||
class TreeView(MultipleInstance):
|
||||
"""
|
||||
Interactive TreeView component with hierarchical data visualization.
|
||||
|
||||
Supports:
|
||||
- Expand/collapse nodes
|
||||
- Add child/sibling nodes
|
||||
- Inline rename
|
||||
- Delete nodes
|
||||
- Node selection
|
||||
"""
|
||||
|
||||
def __init__(self, parent, items: Optional[dict] = None, _id: Optional[str] = None):
|
||||
"""
|
||||
Initialize TreeView component.
|
||||
|
||||
Args:
|
||||
parent: Parent instance
|
||||
items: Optional initial items dictionary {node_id: TreeNode}
|
||||
_id: Optional custom ID
|
||||
"""
|
||||
super().__init__(parent, _id=_id)
|
||||
self._state = TreeViewState(self)
|
||||
self.commands = Commands(self)
|
||||
|
||||
if items:
|
||||
self._state.items = items
|
||||
|
||||
def set_icon_config(self, config: dict[str, str]):
|
||||
"""
|
||||
Set icon configuration for node types.
|
||||
|
||||
Args:
|
||||
config: Dictionary mapping node types to icon identifiers
|
||||
Format: {type: "provider.icon_name"}
|
||||
"""
|
||||
self._state.icon_config = config
|
||||
|
||||
def add_node(self, node: TreeNode, parent_id: Optional[str] = None, insert_index: Optional[int] = None):
|
||||
"""
|
||||
Add a node to the tree.
|
||||
|
||||
Args:
|
||||
node: TreeNode instance to add
|
||||
parent_id: Optional parent node ID (None for root)
|
||||
insert_index: Optional index to insert at in parent's children list.
|
||||
If None, appends to end. If provided, inserts at that position.
|
||||
"""
|
||||
self._state.items[node.id] = node
|
||||
node.parent = parent_id
|
||||
|
||||
if parent_id and parent_id in self._state.items:
|
||||
parent = self._state.items[parent_id]
|
||||
if node.id not in parent.children:
|
||||
if insert_index is not None:
|
||||
parent.children.insert(insert_index, node.id)
|
||||
else:
|
||||
parent.children.append(node.id)
|
||||
|
||||
def expand_all(self):
|
||||
"""Expand all nodes that have children."""
|
||||
for node_id, node in self._state.items.items():
|
||||
if node.children and node_id not in self._state.opened:
|
||||
self._state.opened.append(node_id)
|
||||
|
||||
def _toggle_node(self, node_id: str):
|
||||
"""Toggle expand/collapse state of a node."""
|
||||
if node_id in self._state.opened:
|
||||
self._state.opened.remove(node_id)
|
||||
else:
|
||||
self._state.opened.append(node_id)
|
||||
return self
|
||||
|
||||
def _add_child(self, parent_id: str, new_label: Optional[str] = None):
|
||||
"""Add a child node to a parent."""
|
||||
if parent_id not in self._state.items:
|
||||
raise ValueError(f"Parent node {parent_id} does not exist")
|
||||
|
||||
parent = self._state.items[parent_id]
|
||||
new_node = TreeNode(
|
||||
label=new_label or "New Node",
|
||||
type=parent.type
|
||||
)
|
||||
|
||||
self.add_node(new_node, parent_id=parent_id)
|
||||
|
||||
# Auto-expand parent
|
||||
if parent_id not in self._state.opened:
|
||||
self._state.opened.append(parent_id)
|
||||
|
||||
return self
|
||||
|
||||
def _add_sibling(self, node_id: str, new_label: Optional[str] = None):
|
||||
"""Add a sibling node next to a node."""
|
||||
if node_id not in self._state.items:
|
||||
raise ValueError(f"Node {node_id} does not exist")
|
||||
|
||||
node = self._state.items[node_id]
|
||||
|
||||
if node.parent is None:
|
||||
raise ValueError("Cannot add sibling to root node")
|
||||
|
||||
parent = self._state.items[node.parent]
|
||||
new_node = TreeNode(
|
||||
label=new_label or "New Node",
|
||||
type=node.type
|
||||
)
|
||||
|
||||
# Insert after current node
|
||||
insert_idx = parent.children.index(node_id) + 1
|
||||
self.add_node(new_node, parent_id=node.parent, insert_index=insert_idx)
|
||||
|
||||
return self
|
||||
|
||||
def _start_rename(self, node_id: str):
|
||||
"""Start renaming a node (sets editing state)."""
|
||||
if node_id not in self._state.items:
|
||||
raise ValueError(f"Node {node_id} does not exist")
|
||||
|
||||
self._state.editing = node_id
|
||||
return self
|
||||
|
||||
def _save_rename(self, node_id: str, node_label: str):
|
||||
"""Save renamed node with new label."""
|
||||
if node_id not in self._state.items:
|
||||
raise ValueError(f"Node {node_id} does not exist")
|
||||
|
||||
self._state.items[node_id].label = node_label
|
||||
self._state.editing = None
|
||||
return self
|
||||
|
||||
def _cancel_rename(self):
|
||||
"""Cancel renaming operation."""
|
||||
self._state.editing = None
|
||||
return self
|
||||
|
||||
def _delete_node(self, node_id: str):
|
||||
"""Delete a node (only if it has no children)."""
|
||||
if node_id not in self._state.items:
|
||||
raise ValueError(f"Node {node_id} does not exist")
|
||||
|
||||
node = self._state.items[node_id]
|
||||
|
||||
if node.children:
|
||||
raise ValueError(f"Cannot delete node {node_id} with children")
|
||||
|
||||
# Remove from parent's children list
|
||||
if node.parent and node.parent in self._state.items:
|
||||
parent = self._state.items[node.parent]
|
||||
parent.children.remove(node_id)
|
||||
|
||||
# Remove from state
|
||||
del self._state.items[node_id]
|
||||
|
||||
if node_id in self._state.opened:
|
||||
self._state.opened.remove(node_id)
|
||||
|
||||
if self._state.selected == node_id:
|
||||
self._state.selected = None
|
||||
|
||||
return self
|
||||
|
||||
def _select_node(self, node_id: str):
|
||||
"""Select a node."""
|
||||
if node_id not in self._state.items:
|
||||
raise ValueError(f"Node {node_id} does not exist")
|
||||
|
||||
self._state.selected = node_id
|
||||
return self
|
||||
|
||||
def _render_action_buttons(self, node_id: str):
|
||||
"""Render action buttons for a node (visible on hover)."""
|
||||
return Div(
|
||||
mk.icon(add_circle20_regular, command=self.commands.add_child(node_id)),
|
||||
mk.icon(edit20_regular, command=self.commands.start_rename(node_id)),
|
||||
mk.icon(delete20_regular, command=self.commands.delete_node(node_id)),
|
||||
cls="mf-treenode-actions"
|
||||
)
|
||||
|
||||
def _render_node(self, node_id: str, level: int = 0):
|
||||
"""
|
||||
Render a single node and its children recursively.
|
||||
|
||||
Args:
|
||||
node_id: ID of node to render
|
||||
level: Indentation level
|
||||
|
||||
Returns:
|
||||
Div containing the node and its children
|
||||
"""
|
||||
node = self._state.items[node_id]
|
||||
is_expanded = node_id in self._state.opened
|
||||
is_selected = node_id == self._state.selected
|
||||
is_editing = node_id == self._state.editing
|
||||
has_children = len(node.children) > 0
|
||||
|
||||
# Toggle icon
|
||||
toggle = mk.icon(
|
||||
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else " ",
|
||||
command=self.commands.toggle_node(node_id))
|
||||
|
||||
# Label or input for editing
|
||||
if is_editing:
|
||||
# TODO: Bind input to save_rename (Enter) and cancel_rename (Escape)
|
||||
label_element = mk.mk(Input(
|
||||
name="node_label",
|
||||
value=node.label,
|
||||
cls="mf-treenode-input input input-sm"
|
||||
), command=self.commands.save_rename(node_id))
|
||||
else:
|
||||
label_element = mk.mk(
|
||||
Span(node.label, cls="mf-treenode-label text-sm"),
|
||||
command=self.commands.select_node(node_id)
|
||||
)
|
||||
|
||||
# Node element
|
||||
node_element = Div(
|
||||
toggle,
|
||||
label_element,
|
||||
self._render_action_buttons(node_id),
|
||||
cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}",
|
||||
data_node_id=node_id,
|
||||
style=f"padding-left: {level * 20}px"
|
||||
)
|
||||
|
||||
# Children (if expanded)
|
||||
children_elements = []
|
||||
if is_expanded and has_children:
|
||||
for child_id in node.children:
|
||||
children_elements.append(
|
||||
self._render_node(child_id, level + 1)
|
||||
)
|
||||
|
||||
return Div(
|
||||
node_element,
|
||||
*children_elements,
|
||||
cls="mf-treenode-container"
|
||||
)
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the complete TreeView.
|
||||
|
||||
Returns:
|
||||
Div: Complete TreeView HTML structure
|
||||
"""
|
||||
# Find root nodes (nodes without parent)
|
||||
root_nodes = [
|
||||
node_id for node_id, node in self._state.items.items()
|
||||
if node.parent is None
|
||||
]
|
||||
|
||||
return Div(
|
||||
*[self._render_node(node_id) for node_id in root_nodes],
|
||||
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="_keyboard"),
|
||||
id=self._id,
|
||||
cls="mf-treeview"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
"""FastHTML magic method for rendering."""
|
||||
return self.render()
|
||||
@@ -1,10 +1,9 @@
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.AuthProxy import AuthProxy
|
||||
from myfasthtml.controls.helpers import Ids, mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, RootInstance
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.utils import retrieve_user_info
|
||||
from myfasthtml.icons.material import dark_mode_filled, person_outline_sharp
|
||||
from myfasthtml.icons.material_p1 import light_mode_filled, alternate_email_filled
|
||||
@@ -16,7 +15,6 @@ class UserProfileState:
|
||||
self._session = owner.get_session()
|
||||
|
||||
self.theme = "light"
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
user_info = retrieve_user_info(self._session)
|
||||
@@ -27,7 +25,7 @@ class UserProfileState:
|
||||
|
||||
def save(self):
|
||||
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
||||
auth_proxy = AuthProxy(RootInstance)
|
||||
auth_proxy = InstancesManager.get_auth_proxy()
|
||||
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
|
||||
|
||||
|
||||
@@ -37,15 +35,14 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
class UserProfile(SingleInstance):
|
||||
def __init__(self, parent=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
def __init__(self, session):
|
||||
super().__init__(session, Ids.UserProfile)
|
||||
self._state = UserProfileState(self)
|
||||
self._commands = Commands(self)
|
||||
|
||||
def update_dark_mode(self, client_response):
|
||||
self._state.theme = client_response.get("theme", "light")
|
||||
self._state.save()
|
||||
retrieve_user_info(self._session).get("user_settings", {})["theme"] = self._state.theme
|
||||
|
||||
def render(self):
|
||||
user_info = retrieve_user_info(self._session)
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fasthtml.components import Script, Div
|
||||
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
logger = logging.getLogger("VisNetwork")
|
||||
|
||||
|
||||
class VisNetworkState(DbObject):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
with self.initializing():
|
||||
# persisted in DB
|
||||
self.nodes: list = []
|
||||
self.edges: list = []
|
||||
self.options: dict = {
|
||||
"autoResize": True,
|
||||
"interaction": {
|
||||
"dragNodes": True,
|
||||
"zoomView": True,
|
||||
"dragView": True,
|
||||
},
|
||||
"physics": {"enabled": True}
|
||||
}
|
||||
|
||||
|
||||
class VisNetwork(MultipleInstance):
|
||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
logger.debug(f"VisNetwork created with id: {self._id}")
|
||||
|
||||
self._state = VisNetworkState(self)
|
||||
self._update_state(nodes, edges, options)
|
||||
|
||||
def _update_state(self, nodes, edges, options):
|
||||
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}")
|
||||
if not nodes and not edges and not options:
|
||||
return
|
||||
|
||||
state = self._state.copy()
|
||||
if nodes is not None:
|
||||
state.nodes = nodes
|
||||
if edges is not None:
|
||||
state.edges = edges
|
||||
if options is not None:
|
||||
state.options = options
|
||||
|
||||
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):
|
||||
|
||||
# Serialize nodes and edges to JSON
|
||||
# This preserves all properties (color, shape, size, etc.) that are present
|
||||
js_nodes = ",\n ".join(
|
||||
json.dumps(node) for node in self._state.nodes
|
||||
)
|
||||
js_edges = ",\n ".join(
|
||||
json.dumps(edge) for edge in self._state.edges
|
||||
)
|
||||
|
||||
# Convert Python options to JS
|
||||
js_options = json.dumps(self._state.options, indent=2)
|
||||
|
||||
return (
|
||||
Div(
|
||||
id=self._id,
|
||||
cls="mf-vis",
|
||||
),
|
||||
|
||||
# The script initializing Vis.js
|
||||
Script(f"""
|
||||
(function() {{
|
||||
const container = document.getElementById("{self._id}");
|
||||
const nodes = new vis.DataSet([
|
||||
{js_nodes}
|
||||
]);
|
||||
const edges = new vis.DataSet([
|
||||
{js_edges}
|
||||
]);
|
||||
const data = {{
|
||||
nodes: nodes,
|
||||
edges: edges
|
||||
}};
|
||||
const options = {js_options};
|
||||
const network = new vis.Network(container, data, options);
|
||||
}})();
|
||||
""")
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -7,72 +7,27 @@ from myfasthtml.core.utils import merge_classes
|
||||
|
||||
class Ids:
|
||||
# Please keep the alphabetical order
|
||||
Root = "mf-root"
|
||||
UserSession = "mf-user_session"
|
||||
AuthProxy = "mf-auth-proxy"
|
||||
DbManager = "mf-dbmanager"
|
||||
Layout = "mf-layout"
|
||||
TabsManager = "mf-tabs-manager"
|
||||
UserProfile = "mf-user-profile"
|
||||
|
||||
|
||||
class mk:
|
||||
|
||||
@staticmethod
|
||||
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
||||
"""
|
||||
Defines a static method for creating a Button object with specific configurations.
|
||||
|
||||
This method constructs a Button instance by wrapping an element with
|
||||
additional configurations such as commands and bindings. Any extra keyword
|
||||
arguments are passed when creating the Button.
|
||||
|
||||
:param element: The underlying widget or element to be wrapped in a Button.
|
||||
:param command: An optional command to associate with the Button. Defaults to None.
|
||||
:param binding: An optional event binding to associate with the Button. Defaults to None.
|
||||
:param kwargs: Additional keyword arguments to further configure the Button.
|
||||
:return: A fully constructed Button instance with the specified configurations.
|
||||
"""
|
||||
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
|
||||
|
||||
@staticmethod
|
||||
def dialog_buttons(ok_title: str = "OK",
|
||||
cancel_title: str = "Cancel",
|
||||
on_ok: Command = None,
|
||||
on_cancel: Command = None,
|
||||
cls=None):
|
||||
return Div(
|
||||
Div(
|
||||
mk.button(ok_title, cls="btn btn-primary btn-sm mr-2", command=on_ok),
|
||||
mk.button(cancel_title, cls="btn btn-ghost btn-sm", command=on_cancel),
|
||||
cls="flex justify-end"
|
||||
),
|
||||
cls=merge_classes("flex justify-end w-full mt-1", cls)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def icon(icon,
|
||||
size=20,
|
||||
def icon(icon, size=20,
|
||||
can_select=True,
|
||||
can_hover=False,
|
||||
cls='',
|
||||
command: Command = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
"""
|
||||
Generates an icon element with customizable properties for size, class, and interactivity.
|
||||
|
||||
This method creates an icon element wrapped in a container with optional classes
|
||||
and event bindings. The icon can be styled and its behavior defined using the parameters
|
||||
provided, allowing for dynamic and reusable UI components.
|
||||
|
||||
:param icon: The icon to display inside the container.
|
||||
:param size: The size of the icon, specified in pixels. Defaults to 20.
|
||||
:param can_select: Indicates whether the icon can be selected. Defaults to True.
|
||||
:param can_hover: Indicates whether the icon reacts to hovering. Defaults to False.
|
||||
:param cls: A string of custom CSS classes to be added to the icon container.
|
||||
:param command: The command object defining the function to be executed on icon interaction.
|
||||
:param binding: The binding object for configuring additional event listeners on the icon.
|
||||
:param kwargs: Additional keyword arguments for configuring attributes and behaviors of the
|
||||
icon element.
|
||||
:return: A styled and interactive icon element embedded inside a container, configured
|
||||
with the defined classes, size, and behaviors.
|
||||
"""
|
||||
merged_cls = merge_classes(f"mf-icon-{size}",
|
||||
'icon-btn' if can_select else '',
|
||||
'mmt-btn' if can_hover else '',
|
||||
@@ -81,27 +36,6 @@ class mk:
|
||||
|
||||
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
|
||||
|
||||
@staticmethod
|
||||
def label(text: str,
|
||||
icon=None,
|
||||
size: str = "sm",
|
||||
cls='',
|
||||
command: Command = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
merged_cls = merge_classes("flex", cls, kwargs)
|
||||
icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None
|
||||
text_part = Span(text, cls=f"text-{size}")
|
||||
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
|
||||
|
||||
@staticmethod
|
||||
def convert_size(size: str):
|
||||
return (size.replace("xs", "16").
|
||||
replace("sm", "20").
|
||||
replace("md", "24").
|
||||
replace("lg", "28").
|
||||
replace("xl", "32"))
|
||||
|
||||
@staticmethod
|
||||
def manage_command(ft, command: Command):
|
||||
if command:
|
||||
@@ -125,6 +59,6 @@ class mk:
|
||||
|
||||
@staticmethod
|
||||
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
|
||||
ft = mk.manage_command(ft, command) if command else ft
|
||||
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
|
||||
ft = mk.manage_command(ft, command)
|
||||
ft = mk.manage_binding(ft, binding, init_binding=init_binding)
|
||||
return ft
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from myfasthtml.auth.utils import login_user, save_user_info, register_user
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.controls.helpers import Ids
|
||||
from myfasthtml.core.instances import special_session, UniqueInstance
|
||||
|
||||
|
||||
class AuthProxy(SingleInstance):
|
||||
def __init__(self, parent, base_url: str = None):
|
||||
super().__init__(parent)
|
||||
class AuthProxy(UniqueInstance):
|
||||
def __init__(self, base_url: str = None):
|
||||
super().__init__(special_session, Ids.AuthProxy)
|
||||
self._base_url = base_url
|
||||
|
||||
def login_user(self, email: str, password: str):
|
||||
|
||||
@@ -129,14 +129,6 @@ class DataConverter:
|
||||
pass
|
||||
|
||||
|
||||
class LambdaConverter(DataConverter):
|
||||
def __init__(self, func):
|
||||
self.func = func
|
||||
|
||||
def convert(self, data):
|
||||
return self.func(data)
|
||||
|
||||
|
||||
class BooleanConverter(DataConverter):
|
||||
def convert(self, data):
|
||||
if data is None:
|
||||
|
||||
@@ -30,7 +30,6 @@ class BaseCommand:
|
||||
self.description = description
|
||||
self._htmx_extra = {}
|
||||
self._bindings = []
|
||||
self._ft = None
|
||||
|
||||
# register the command
|
||||
CommandsManager.register(self)
|
||||
@@ -39,14 +38,13 @@ class BaseCommand:
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": {"c_id": f"{self.id}"},
|
||||
"hx-vals": f'{{"c_id": "{self.id}"}}',
|
||||
} | self._htmx_extra
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
raise NotImplementedError
|
||||
|
||||
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None):
|
||||
# Note that the default value is the same than in get_htmx_params()
|
||||
def htmx(self, target="this", swap="innerHTML"):
|
||||
if target is None:
|
||||
self._htmx_extra["hx-swap"] = "none"
|
||||
elif target != "this":
|
||||
@@ -54,12 +52,8 @@ class BaseCommand:
|
||||
|
||||
if swap is None:
|
||||
self._htmx_extra["hx-swap"] = "none"
|
||||
elif swap != "outerHTML":
|
||||
elif swap != "innerHTML":
|
||||
self._htmx_extra["hx-swap"] = swap
|
||||
|
||||
if trigger is not None:
|
||||
self._htmx_extra["hx-trigger"] = trigger
|
||||
|
||||
return self
|
||||
|
||||
def bind_ft(self, ft):
|
||||
@@ -69,7 +63,6 @@ class BaseCommand:
|
||||
:param ft:
|
||||
:return:
|
||||
"""
|
||||
self._ft = ft
|
||||
htmx = self.get_htmx_params()
|
||||
ft.attrs |= htmx
|
||||
return ft
|
||||
@@ -93,13 +86,6 @@ class BaseCommand:
|
||||
|
||||
return self
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
||||
|
||||
def get_ft(self):
|
||||
return self._ft
|
||||
|
||||
def __str__(self):
|
||||
return f"Command({self.name})"
|
||||
|
||||
@@ -130,19 +116,6 @@ class Command(BaseCommand):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def _convert(self, key, value):
|
||||
if key in self.callback_parameters:
|
||||
param = self.callback_parameters[key]
|
||||
if param.annotation == bool:
|
||||
return value == "true"
|
||||
elif param.annotation == int:
|
||||
return int(value)
|
||||
elif param.annotation == float:
|
||||
return float(value)
|
||||
elif param.annotation == list:
|
||||
return value.split(",")
|
||||
return value
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
ret_from_bindings = []
|
||||
|
||||
@@ -156,7 +129,7 @@ class Command(BaseCommand):
|
||||
if client_response:
|
||||
for k, v in client_response.items():
|
||||
if k in self.callback_parameters:
|
||||
new_kwargs[k] = self._convert(k, v)
|
||||
new_kwargs[k] = v
|
||||
if 'client_response' in self.callback_parameters:
|
||||
new_kwargs['client_response'] = client_response
|
||||
|
||||
@@ -168,8 +141,8 @@ class Command(BaseCommand):
|
||||
# Set the hx-swap-oob attribute on all elements returned by the callback
|
||||
if isinstance(ret, (list, tuple)):
|
||||
for r in ret[1:]:
|
||||
if hasattr(r, 'attrs') and r.get("id", None) is not None:
|
||||
r.attrs["hx-swap-oob"] = r.attrs.get("hx-swap-oob", "true")
|
||||
if hasattr(r, 'attrs'):
|
||||
r.attrs["hx-swap-oob"] = "true"
|
||||
|
||||
if not ret_from_bindings:
|
||||
return ret
|
||||
@@ -180,15 +153,6 @@ class Command(BaseCommand):
|
||||
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:
|
||||
commands = {}
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ from types import SimpleNamespace
|
||||
|
||||
from dbengine.dbengine import DbEngine
|
||||
|
||||
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||
from myfasthtml.controls.helpers import Ids
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.utils import retrieve_user_info
|
||||
|
||||
|
||||
class DbManager(SingleInstance):
|
||||
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
|
||||
super().__init__(parent, auto_register=auto_register)
|
||||
|
||||
def __init__(self, session, root=".myFastHtmlDb", auto_register: bool = True):
|
||||
super().__init__(session, Ids.DbManager, auto_register=auto_register)
|
||||
self.db = DbEngine(root=root)
|
||||
|
||||
def save(self, entry, obj):
|
||||
@@ -35,12 +35,12 @@ class DbObject:
|
||||
It loads from DB at startup
|
||||
"""
|
||||
_initializing = False
|
||||
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
|
||||
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_session", "_forbidden_attrs"}
|
||||
|
||||
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
|
||||
self._owner = owner
|
||||
def __init__(self, session, name=None, db_manager=None):
|
||||
self._session = session
|
||||
self._name = name or self.__class__.__name__
|
||||
self._db_manager = db_manager or DbManager(self._owner)
|
||||
self._db_manager = db_manager or InstancesManager.get(self._session, Ids.DbManager, DbManager)
|
||||
|
||||
self._finalize_initialization()
|
||||
|
||||
@@ -55,7 +55,7 @@ class DbObject:
|
||||
self._initializing = old_state
|
||||
|
||||
def __setattr__(self, name: str, value: str):
|
||||
if name.startswith("_") or name.startswith("ns") or getattr(self, "_initializing", False):
|
||||
if name.startswith("_") or getattr(self, "_initializing", False):
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
|
||||
@@ -74,8 +74,7 @@ class DbObject:
|
||||
self._save_self()
|
||||
|
||||
def _save_self(self):
|
||||
props = {k: getattr(self, k) for k, v in self._get_properties().items() if
|
||||
not k.startswith("_") and not k.startswith("ns")}
|
||||
props = {k: getattr(self, k) for k, v in self._get_properties().items() if not k.startswith("_")}
|
||||
if props:
|
||||
self._db_manager.save(self._name, props)
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from myfasthtml.controls.helpers import Ids
|
||||
from myfasthtml.core.utils import pascal_to_snake
|
||||
|
||||
logger = logging.getLogger("InstancesManager")
|
||||
|
||||
special_session = {
|
||||
"user_info": {"id": "** SPECIAL SESSION **"}
|
||||
@@ -22,92 +17,21 @@ class BaseInstance:
|
||||
Base class for all instances (manageable by InstancesManager)
|
||||
"""
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
# Extract arguments from both positional and keyword arguments
|
||||
# Signature matches __init__: parent, session=None, _id=None, auto_register=True
|
||||
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._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()
|
||||
|
||||
def __init__(self, session: dict, prefix: str, _id: str, auto_register: bool = True):
|
||||
self._session = session
|
||||
self._id = _id
|
||||
self._prefix = prefix
|
||||
if auto_register:
|
||||
InstancesManager.register(self._session, self)
|
||||
InstancesManager.register(session, self)
|
||||
|
||||
def get_session(self) -> dict:
|
||||
return self._session
|
||||
|
||||
def get_id(self) -> str:
|
||||
def get_id(self):
|
||||
return self._id
|
||||
|
||||
def get_parent(self) -> Optional['BaseInstance']:
|
||||
return self._parent
|
||||
def get_session(self):
|
||||
return self._session
|
||||
|
||||
def get_prefix(self) -> str:
|
||||
def get_prefix(self):
|
||||
return self._prefix
|
||||
|
||||
def get_full_id(self) -> str:
|
||||
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):
|
||||
@@ -115,26 +39,19 @@ class SingleInstance(BaseInstance):
|
||||
Base class for instances that can only have one instance at a time.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
parent: Optional[BaseInstance] = None,
|
||||
session: Optional[dict] = None,
|
||||
_id: Optional[str] = None,
|
||||
auto_register: bool = True):
|
||||
super().__init__(parent, session, _id, auto_register)
|
||||
def __init__(self, session: dict, prefix: str, auto_register: bool = True):
|
||||
super().__init__(session, prefix, prefix, auto_register)
|
||||
|
||||
|
||||
class UniqueInstance(BaseInstance):
|
||||
"""
|
||||
Base class for instances that can only have one instance at a time.
|
||||
But unlike SingleInstance, the __init__ is called every time it's instantiated.
|
||||
Does not throw exception if the instance already exists, it simply overwrites it.
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
parent: Optional[BaseInstance] = None,
|
||||
session: Optional[dict] = None,
|
||||
_id: Optional[str] = None,
|
||||
auto_register: bool = True):
|
||||
super().__init__(parent, session, _id, auto_register)
|
||||
def __init__(self, session: dict, prefix: str, auto_register: bool = True):
|
||||
super().__init__(session, prefix, prefix, auto_register)
|
||||
self._prefix = prefix
|
||||
|
||||
|
||||
class MultipleInstance(BaseInstance):
|
||||
@@ -142,11 +59,9 @@ class MultipleInstance(BaseInstance):
|
||||
Base class for instances that can have multiple instances at a time.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: BaseInstance,
|
||||
session: Optional[dict] = None,
|
||||
_id: Optional[str] = None,
|
||||
auto_register: bool = True):
|
||||
super().__init__(parent, session, _id, auto_register)
|
||||
def __init__(self, session: dict, prefix: str, auto_register: bool = True, _id=None):
|
||||
super().__init__(session, prefix, f"{prefix}-{_id or str(uuid.uuid4())}", auto_register)
|
||||
self._prefix = prefix
|
||||
|
||||
|
||||
class InstancesManager:
|
||||
@@ -160,7 +75,7 @@ class InstancesManager:
|
||||
:param instance:
|
||||
: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:
|
||||
raise DuplicateInstanceError(instance)
|
||||
@@ -169,50 +84,38 @@ class InstancesManager:
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
def get(session: dict, instance_id: str):
|
||||
def get(session: dict, instance_id: str, instance_type: type = None, *args, **kwargs):
|
||||
"""
|
||||
Get or create an instance of the given type (from its id)
|
||||
:param session:
|
||||
:param instance_id:
|
||||
:param instance_type:
|
||||
:param args:
|
||||
:param kwargs:
|
||||
:return:
|
||||
"""
|
||||
key = (InstancesManager.get_session_id(session), instance_id)
|
||||
return InstancesManager.instances[key]
|
||||
try:
|
||||
key = (InstancesManager._get_session_id(session), instance_id)
|
||||
|
||||
return InstancesManager.instances[key]
|
||||
except KeyError:
|
||||
if instance_type:
|
||||
return instance_type(session, *args, **kwargs) # it will be automatically registered
|
||||
else:
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def get_session_id(session):
|
||||
if session is None:
|
||||
def _get_session_id(session):
|
||||
if not session:
|
||||
return "** NOT LOGGED IN **"
|
||||
if "user_info" not in session:
|
||||
return "** UNKNOWN USER **"
|
||||
return session["user_info"].get("id", "** INVALID SESSION **")
|
||||
|
||||
@staticmethod
|
||||
def get_session_user_name(session):
|
||||
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 **")
|
||||
def get_auth_proxy():
|
||||
return InstancesManager.get(special_session, Ids.AuthProxy)
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
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(None, special_session, Ids.Root)
|
||||
return InstancesManager.instances.clear()
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _is_subsequence(query: str, target: str) -> tuple[bool, float]:
|
||||
"""
|
||||
Determines if a query string is a subsequence of a target string and calculates
|
||||
a score based on the compactness of the match. The match is case-insensitive.
|
||||
|
||||
The function iterates through each character of the query and checks if it
|
||||
exists in the target string while maintaining the order. If all characters of
|
||||
the query are found in order, it calculates a score based on the smallest
|
||||
window in the target that contains all the matched characters.
|
||||
|
||||
:param query: The query string to check as a subsequence.
|
||||
:param target: The target string in which to find the subsequence.
|
||||
:return: A tuple where the first value is a boolean indicating if a valid
|
||||
subsequence exists, and the second value is a float representing the
|
||||
compactness score of the match.
|
||||
:rtype: tuple[bool, float]
|
||||
"""
|
||||
query = query.lower()
|
||||
target = target.lower()
|
||||
|
||||
positions = []
|
||||
idx = 0
|
||||
|
||||
for char in query:
|
||||
idx = target.find(char, idx)
|
||||
if idx == -1:
|
||||
return False, 0.0
|
||||
positions.append(idx)
|
||||
idx += 1
|
||||
|
||||
# Smallest window containing all matched chars
|
||||
window_size = positions[-1] - positions[0] + 1
|
||||
|
||||
# Score: ratio of query length vs window size (compactness)
|
||||
score = len(query) / window_size
|
||||
|
||||
return True, score
|
||||
|
||||
|
||||
def fuzzy_matching(query: str, choices: list[Any], similarity_threshold: float = 0.7, get_attr=None):
|
||||
"""
|
||||
Perform fuzzy matching on a list of items to find the items that are similar
|
||||
to the given query based on a similarity threshold.
|
||||
|
||||
:param query: The search query to be matched, provided as a string.
|
||||
:param choices: A list of strings representing the items to be compared against the query.
|
||||
:param similarity_threshold: A float value representing the minimum similarity score
|
||||
(between 0 and 1) an item needs to achieve to be considered a match. Defaults to 0.7.
|
||||
:param get_attr: When choice is a object, give the property to use
|
||||
:return: A list of strings containing the items from the input list that meet or exceed
|
||||
the similarity threshold, sorted in descending order of similarity.
|
||||
"""
|
||||
get_attr = get_attr or (lambda x: x)
|
||||
matches = []
|
||||
for file_doc in choices:
|
||||
# Calculate similarity between search term and filename
|
||||
similarity = SequenceMatcher(None, query.lower(), get_attr(file_doc).lower()).ratio()
|
||||
|
||||
if similarity >= similarity_threshold:
|
||||
matches.append((file_doc, similarity))
|
||||
|
||||
# Sort by similarity score (highest first)
|
||||
matches.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Return only the FileDocument objects
|
||||
return [match[0] for match in matches]
|
||||
|
||||
|
||||
def subsequence_matching(query: str, choices: list[Any], get_attr=None):
|
||||
get_attr = get_attr or (lambda x: x)
|
||||
matches = []
|
||||
for item in choices:
|
||||
matched, score = _is_subsequence(query, get_attr(item))
|
||||
if matched:
|
||||
matches.append((item, score))
|
||||
|
||||
# Sort by score (highest first)
|
||||
matches.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
# Return only the FileDocument objects
|
||||
return [match[0] for match in matches]
|
||||
@@ -1,238 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
ROOT_COLOR = "#ff9999"
|
||||
GHOST_COLOR = "#cccccc"
|
||||
|
||||
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
||||
"""
|
||||
Convert a list of nested dictionaries to vis.js nodes and edges format.
|
||||
|
||||
Args:
|
||||
trees: List of nested dictionaries where keys are node names and values are
|
||||
dictionaries of children (e.g., [{"root1": {"child1": {}}}, {"root2": {}}])
|
||||
|
||||
Returns:
|
||||
tuple: (nodes, edges) where:
|
||||
- nodes: list of dicts with auto-incremented numeric IDs
|
||||
- edges: list of dicts with 'from' and 'to' keys
|
||||
|
||||
Example:
|
||||
>>> trees = [{"root1": {"child1": {}}}, {"root2": {"child2": {}}}]
|
||||
>>> nodes, edges = from_nested_dict(trees)
|
||||
>>> # nodes = [{"id": 1, "label": "root1"}, {"id": 2, "label": "child1"}, ...]
|
||||
"""
|
||||
nodes = []
|
||||
edges = []
|
||||
node_id_counter = 1
|
||||
node_id_map = {} # maps node_label -> node_id
|
||||
|
||||
def traverse(subtree: dict, parent_id: int | None = None):
|
||||
nonlocal node_id_counter
|
||||
|
||||
for node_label, children in subtree.items():
|
||||
# Create node with auto-incremented ID
|
||||
current_id = node_id_counter
|
||||
node_id_map[node_label] = current_id
|
||||
nodes.append({
|
||||
"id": current_id,
|
||||
"label": node_label
|
||||
})
|
||||
node_id_counter += 1
|
||||
|
||||
# Create edge from parent to current node
|
||||
if parent_id is not None:
|
||||
edges.append({
|
||||
"from": parent_id,
|
||||
"to": current_id
|
||||
})
|
||||
|
||||
# Recursively process children
|
||||
if children:
|
||||
traverse(children, parent_id=current_id)
|
||||
|
||||
# Process each tree in the list
|
||||
for tree in trees:
|
||||
traverse(tree)
|
||||
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def from_tree_with_metadata(
|
||||
trees: list[dict],
|
||||
id_getter: Callable = None,
|
||||
label_getter: Callable = None,
|
||||
children_getter: Callable = None
|
||||
) -> tuple[list, list]:
|
||||
"""
|
||||
Convert a list of trees with metadata to vis.js nodes and edges format.
|
||||
|
||||
Args:
|
||||
trees: List of dictionaries with 'id', 'label', and 'children' keys
|
||||
(e.g., [{"id": "root1", "label": "Root 1", "children": [...]}, ...])
|
||||
id_getter: Optional callback to extract node ID from dict
|
||||
Default: lambda n: n.get("id")
|
||||
label_getter: Optional callback to extract node label from dict
|
||||
Default: lambda n: n.get("label", "")
|
||||
children_getter: Optional callback to extract children list from dict
|
||||
Default: lambda n: n.get("children", [])
|
||||
|
||||
Returns:
|
||||
tuple: (nodes, edges) where:
|
||||
- nodes: list of dicts with IDs from tree or auto-incremented
|
||||
- edges: list of dicts with 'from' and 'to' keys
|
||||
|
||||
Example:
|
||||
>>> trees = [
|
||||
... {
|
||||
... "id": "root1",
|
||||
... "label": "Root Node 1",
|
||||
... "children": [
|
||||
... {"id": "child1", "label": "Child 1", "children": []}
|
||||
... ]
|
||||
... },
|
||||
... {"id": "root2", "label": "Root Node 2"}
|
||||
... ]
|
||||
>>> nodes, edges = from_tree_with_metadata(trees)
|
||||
"""
|
||||
# Default getters
|
||||
if id_getter is None:
|
||||
id_getter = lambda n: n.get("id")
|
||||
if label_getter is None:
|
||||
label_getter = lambda n: n.get("label", "")
|
||||
if children_getter is None:
|
||||
children_getter = lambda n: n.get("children", [])
|
||||
|
||||
nodes = []
|
||||
edges = []
|
||||
node_id_counter = 1
|
||||
|
||||
def traverse(node_dict: dict, parent_id: int | str | None = None):
|
||||
nonlocal node_id_counter
|
||||
|
||||
# Extract ID (use provided or auto-increment)
|
||||
node_id = id_getter(node_dict)
|
||||
if node_id is None:
|
||||
node_id = node_id_counter
|
||||
node_id_counter += 1
|
||||
|
||||
# Extract label
|
||||
node_label = label_getter(node_dict)
|
||||
|
||||
# Create node
|
||||
nodes.append({
|
||||
"id": node_id,
|
||||
"label": node_label
|
||||
})
|
||||
|
||||
# Create edge from parent to current node
|
||||
if parent_id is not None:
|
||||
edges.append({
|
||||
"from": parent_id,
|
||||
"to": node_id
|
||||
})
|
||||
|
||||
# Recursively process children
|
||||
children = children_getter(node_dict)
|
||||
if children:
|
||||
for child in children:
|
||||
traverse(child, parent_id=node_id)
|
||||
|
||||
# Process each tree in the list
|
||||
for tree in trees:
|
||||
traverse(tree)
|
||||
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def from_parent_child_list(
|
||||
items: list,
|
||||
id_getter: Callable = None,
|
||||
label_getter: Callable = None,
|
||||
parent_getter: Callable = None,
|
||||
ghost_color: str = GHOST_COLOR,
|
||||
root_color: str | None = ROOT_COLOR
|
||||
) -> tuple[list, list]:
|
||||
"""
|
||||
Convert a list of items with parent references to vis.js nodes and edges format.
|
||||
|
||||
Args:
|
||||
items: List of items (dicts or objects) with parent references
|
||||
id_getter: callback to extract node ID
|
||||
label_getter: callback to extract node label
|
||||
parent_getter: callback to extract parent ID
|
||||
ghost_color: color for ghost nodes (referenced parents)
|
||||
root_color: color for root nodes (nodes without parent)
|
||||
|
||||
Returns:
|
||||
tuple: (nodes, edges)
|
||||
"""
|
||||
|
||||
# Default getters
|
||||
if id_getter is None:
|
||||
id_getter = lambda item: item.get("id")
|
||||
|
||||
if label_getter is None:
|
||||
label_getter = lambda item: item.get("label", "")
|
||||
|
||||
if parent_getter is None:
|
||||
parent_getter = lambda item: item.get("parent")
|
||||
|
||||
nodes = []
|
||||
edges = []
|
||||
|
||||
# Track all existing node IDs
|
||||
existing_ids = set()
|
||||
|
||||
# First pass: create nodes for all items
|
||||
for item in items:
|
||||
node_id = id_getter(item)
|
||||
node_label = label_getter(item)
|
||||
|
||||
existing_ids.add(node_id)
|
||||
nodes.append({
|
||||
"id": node_id,
|
||||
"label": node_label,
|
||||
# root color assigned later
|
||||
})
|
||||
|
||||
# Track ghost nodes
|
||||
ghost_nodes = set()
|
||||
|
||||
# Track which nodes have parents
|
||||
nodes_with_parent = set()
|
||||
|
||||
# Second pass: create edges and detect ghost nodes
|
||||
for item in items:
|
||||
node_id = id_getter(item)
|
||||
parent_id = parent_getter(item)
|
||||
|
||||
# Skip roots
|
||||
if parent_id is None or parent_id == "":
|
||||
continue
|
||||
|
||||
# Child has a parent
|
||||
nodes_with_parent.add(node_id)
|
||||
|
||||
# Create edge parent → child
|
||||
edges.append({
|
||||
"from": parent_id,
|
||||
"to": node_id
|
||||
})
|
||||
|
||||
# Create ghost node if parent not found
|
||||
if parent_id not in existing_ids and parent_id not in ghost_nodes:
|
||||
ghost_nodes.add(parent_id)
|
||||
nodes.append({
|
||||
"id": parent_id,
|
||||
"label": str(parent_id),
|
||||
"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
|
||||
@@ -1,5 +1,4 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from bs4 import Tag
|
||||
from fastcore.xml import FT
|
||||
@@ -224,42 +223,116 @@ def debug_session(session):
|
||||
return session.get("user_info", {}).get("email", "** UNKNOWN USER **")
|
||||
|
||||
|
||||
def get_id(obj):
|
||||
if isinstance(obj, str):
|
||||
return obj
|
||||
elif hasattr(obj, "id"):
|
||||
return obj.id
|
||||
elif hasattr(obj, "get_id"):
|
||||
return obj.get_id()
|
||||
else:
|
||||
return str(obj)
|
||||
import inspect
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""Convert a PascalCase or CamelCase string to snake_case."""
|
||||
if name is None:
|
||||
return None
|
||||
def build_args_kwargs(
|
||||
parameters,
|
||||
values,
|
||||
default_args: Optional[list] = None,
|
||||
default_kwargs: Optional[dict] = None
|
||||
):
|
||||
"""
|
||||
Build (args, kwargs) from a sequence or dict of inspect.Parameter and a dict of values.
|
||||
|
||||
- POSITIONAL_ONLY and POSITIONAL_OR_KEYWORD fill `args`
|
||||
- KEYWORD_ONLY fill `kwargs`
|
||||
- VAR_POSITIONAL (*args) accepts list/tuple values
|
||||
- VAR_KEYWORD (**kwargs) accepts dict values
|
||||
- If not found, fallback to default_args / default_kwargs when provided
|
||||
- Raises ValueError for missing required or unknown parameters
|
||||
"""
|
||||
|
||||
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
|
||||
if not isinstance(parameters, dict):
|
||||
parameters = {p.name: p for p in parameters}
|
||||
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return ""
|
||||
ordered_params = list(parameters.values())
|
||||
|
||||
# Split on underscores and capitalize each part
|
||||
parts = name.split('_')
|
||||
return ''.join(word.capitalize() for word in parts if word)
|
||||
has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in ordered_params)
|
||||
has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in ordered_params)
|
||||
|
||||
args = []
|
||||
kwargs = {}
|
||||
consumed_names = set()
|
||||
|
||||
default_args = default_args or []
|
||||
default_kwargs = default_kwargs or {}
|
||||
|
||||
# 1 Handle positional parameters
|
||||
positional_params = [
|
||||
p for p in ordered_params
|
||||
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||||
]
|
||||
|
||||
for i, p in enumerate(positional_params):
|
||||
if p.name in values:
|
||||
args.append(values[p.name])
|
||||
consumed_names.add(p.name)
|
||||
elif i < len(default_args):
|
||||
args.append(default_args[i])
|
||||
elif p.default is not inspect.Parameter.empty:
|
||||
args.append(p.default)
|
||||
else:
|
||||
raise ValueError(f"Missing required positional argument: {p.name}")
|
||||
|
||||
# 2 Handle *args
|
||||
for p in ordered_params:
|
||||
if p.kind == inspect.Parameter.VAR_POSITIONAL:
|
||||
if p.name in values:
|
||||
val = values[p.name]
|
||||
if not isinstance(val, (list, tuple)):
|
||||
raise ValueError(f"*{p.name} must be a list or tuple, got {type(val).__name__}")
|
||||
args.extend(val)
|
||||
consumed_names.add(p.name)
|
||||
elif len(default_args) > len(positional_params):
|
||||
# Add any remaining default_args beyond fixed positionals
|
||||
args.extend(default_args[len(positional_params):])
|
||||
|
||||
# 3 Handle keyword-only parameters
|
||||
for p in ordered_params:
|
||||
if p.kind == inspect.Parameter.KEYWORD_ONLY:
|
||||
if p.name in values:
|
||||
kwargs[p.name] = values[p.name]
|
||||
consumed_names.add(p.name)
|
||||
elif p.name in default_kwargs:
|
||||
kwargs[p.name] = default_kwargs[p.name]
|
||||
elif p.default is not inspect.Parameter.empty:
|
||||
kwargs[p.name] = p.default
|
||||
else:
|
||||
raise ValueError(f"Missing required keyword-only argument: {p.name}")
|
||||
|
||||
# 4 Handle **kwargs
|
||||
for p in ordered_params:
|
||||
if p.kind == inspect.Parameter.VAR_KEYWORD:
|
||||
if p.name in values:
|
||||
val = values[p.name]
|
||||
if not isinstance(val, dict):
|
||||
raise ValueError(f"**{p.name} must be a dict, got {type(val).__name__}")
|
||||
kwargs.update(val)
|
||||
consumed_names.add(p.name)
|
||||
# Merge any unmatched names if **kwargs exists
|
||||
remaining = {
|
||||
k: v for k, v in values.items()
|
||||
if k not in consumed_names and k not in parameters
|
||||
}
|
||||
# Also merge default_kwargs not used yet
|
||||
for k, v in default_kwargs.items():
|
||||
if k not in kwargs:
|
||||
kwargs[k] = v
|
||||
kwargs.update(remaining)
|
||||
break
|
||||
|
||||
# 5 Handle unknown / unexpected parameters (if no **kwargs)
|
||||
if not has_var_keyword:
|
||||
unexpected = [k for k in values if k not in consumed_names and k in parameters]
|
||||
if unexpected:
|
||||
raise ValueError(f"Unexpected parameters: {unexpected}")
|
||||
extra = [k for k in values if k not in consumed_names and k not in parameters]
|
||||
if extra:
|
||||
raise ValueError(f"Unknown parameters: {extra}")
|
||||
|
||||
return args, kwargs
|
||||
|
||||
|
||||
@utils_rt(Routes.Commands)
|
||||
@@ -276,7 +349,6 @@ def post(session, c_id: str, client_response: dict = None):
|
||||
from myfasthtml.core.commands import CommandsManager
|
||||
command = CommandsManager.get_command(c_id)
|
||||
if command:
|
||||
logger.debug(f"Executing command {command.name}.")
|
||||
return command.execute(client_response)
|
||||
|
||||
raise ValueError(f"Command with ID '{c_id}' not found.")
|
||||
@@ -295,7 +367,6 @@ def post(session, b_id: str, values: dict):
|
||||
from myfasthtml.core.bindings import BindingsManager
|
||||
binding = BindingsManager.get_binding(b_id)
|
||||
if binding:
|
||||
res = binding.update(values)
|
||||
return res
|
||||
return binding.update(values)
|
||||
|
||||
raise ValueError(f"Binding with ID '{b_id}' not found.")
|
||||
|
||||
@@ -385,7 +385,7 @@ down_to_bottom = NotStr('''<svg name="carbon-DownToBottom" xmlns="http://www.w3.
|
||||
basketball = NotStr('''<svg name="carbon-Basketball" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"><path d="M16 2a14 14 0 1 0 14 14A14.016 14.016 0 0 0 16 2zm11.95 13H22.04a14.409 14.409 0 0 1 2.738-7.153A11.94 11.94 0 0 1 27.95 15zM17 15V4.05a11.918 11.918 0 0 1 6.287 2.438A16.265 16.265 0 0 0 20.04 15zm-2 0h-3.04a16.265 16.265 0 0 0-3.247-8.512A11.918 11.918 0 0 1 15 4.051zm0 2v10.95a11.918 11.918 0 0 1-6.287-2.438A16.265 16.265 0 0 0 11.96 17zm2 0h3.04a16.265 16.265 0 0 0 3.248 8.512A11.918 11.918 0 0 1 17 27.949zM7.22 7.847A14.409 14.409 0 0 1 9.96 15H4.051a11.94 11.94 0 0 1 3.17-7.153zM4.05 17H9.96a14.409 14.409 0 0 1-2.738 7.153A11.94 11.94 0 0 1 4.05 17zm20.73 7.153A14.409 14.409 0 0 1 22.04 17h5.908a11.94 11.94 0 0 1-3.17 7.153z" fill="currentColor" /></svg>''')
|
||||
thunderstorm_scattered_night = NotStr('''<svg name="carbon-ThunderstormScatteredNight" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M13.338 30l-1.736-1l2.287-4H10l3.993-7l1.737 1l-2.284 4h3.891l-3.999 7z" fill="currentColor" /> <path d="M29.844 13.035a1.52 1.52 0 0 0-1.231-.866a5.356 5.356 0 0 1-3.41-1.716A6.465 6.465 0 0 1 23.92 4.06a1.604 1.604 0 0 0-.3-1.546a1.455 1.455 0 0 0-1.36-.492l-.019.004a7.854 7.854 0 0 0-6.105 6.48A7.372 7.372 0 0 0 13.5 8a7.551 7.551 0 0 0-7.15 5.244A5.993 5.993 0 0 0 8 25v-2a3.993 3.993 0 0 1-.673-7.93l.663-.112l.145-.656a5.496 5.496 0 0 1 10.73 0l.145.656l.663.113A3.993 3.993 0 0 1 19 23v2a5.955 5.955 0 0 0 5.88-7.146a7.502 7.502 0 0 0 4.867-3.3a1.537 1.537 0 0 0 .097-1.52zm-5.693 2.918a5.966 5.966 0 0 0-3.502-2.709a7.508 7.508 0 0 0-2.62-3.694a6.008 6.008 0 0 1 3.77-5.333a8.458 8.458 0 0 0 1.939 7.596a7.404 7.404 0 0 0 3.902 2.228a5.442 5.442 0 0 1-3.489 1.912z" fill="currentColor" /></svg>''')
|
||||
letter_uu = NotStr('''<svg name="carbon-LetterUu" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M23 23h-4a2 2 0 0 1-2-2v-8h2v8h4v-8h2v8a2 2 0 0 1-2 2z" fill="currentColor" /> <path d="M13 23H9a2 2 0 0 1-2-2V9h2v12h4V9h2v12a2 2 0 0 1-2 2z" fill="currentColor" /></svg>''')
|
||||
continue_ = NotStr('''<svg name="carbon-Continue" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M10 28a1 1 0 0 1-1-1V5a1 1 0 0 1 1.501-.865l19 11a1 1 0 0 1 0 1.73l-19 11A.998.998 0 0 1 10 28zm1-21.266v18.532L27 16z" fill="currentColor" /> <path d="M4 4h2v24H4z" fill="currentColor" /></svg>''')
|
||||
continue = NotStr('''<svg name="carbon-Continue" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M10 28a1 1 0 0 1-1-1V5a1 1 0 0 1 1.501-.865l19 11a1 1 0 0 1 0 1.73l-19 11A.998.998 0 0 1 10 28zm1-21.266v18.532L27 16z" fill="currentColor" /> <path d="M4 4h2v24H4z" fill="currentColor" /></svg>''')
|
||||
login = NotStr('''<svg name="carbon-Login" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M26 30H14a2 2 0 0 1-2-2v-3h2v3h12V4H14v3h-2V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v24a2 2 0 0 1-2 2z" fill="currentColor" /> <path d="M14.59 20.59L18.17 17H4v-2h14.17l-3.58-3.59L16 10l6 6l-6 6l-1.41-1.41z" fill="currentColor" /></svg>''')
|
||||
favorite = NotStr('''<svg name="carbon-Favorite" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"><path d="M22.45 6a5.47 5.47 0 0 1 3.91 1.64a5.7 5.7 0 0 1 0 8L16 26.13L5.64 15.64a5.7 5.7 0 0 1 0-8a5.48 5.48 0 0 1 7.82 0l2.54 2.6l2.53-2.58A5.44 5.44 0 0 1 22.45 6m0-2a7.47 7.47 0 0 0-5.34 2.24L16 7.36l-1.11-1.12a7.49 7.49 0 0 0-10.68 0a7.72 7.72 0 0 0 0 10.82L16 29l11.79-11.94a7.72 7.72 0 0 0 0-10.82A7.49 7.49 0 0 0 22.45 4z" fill="currentColor" /></svg>''')
|
||||
cd_archive = NotStr('''<svg name="carbon-CdArchive" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32"> <path d="M16 28a12 12 0 1 1 12-12a12 12 0 0 1-12 12zm0-22a10 10 0 1 0 10 10A10 10 0 0 0 16 6z" fill="currentColor" /> <path d="M16 22a6 6 0 1 1 6-6a6 6 0 0 1-6 6zm0-10a4 4 0 1 0 4 4a4 4 0 0 0-4-4z" fill="currentColor" /> <circle cx="16" cy="16" r="2" fill="currentColor" /></svg>''')
|
||||
|
||||
@@ -11,10 +11,6 @@ import re
|
||||
|
||||
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')
|
||||
|
||||
@@ -10,7 +10,6 @@ from starlette.responses import Response
|
||||
from myfasthtml.auth.routes import setup_auth_routes
|
||||
from myfasthtml.auth.utils import create_auth_beforeware
|
||||
from myfasthtml.core.AuthProxy import AuthProxy
|
||||
from myfasthtml.core.instances import RootInstance
|
||||
from myfasthtml.core.utils import utils_app
|
||||
|
||||
logger = logging.getLogger("MyFastHtml")
|
||||
@@ -32,7 +31,6 @@ def get_asset_content(filename):
|
||||
|
||||
|
||||
def create_app(daisyui: Optional[bool] = True,
|
||||
vis: Optional[bool] = True,
|
||||
protect_routes: Optional[bool] = True,
|
||||
mount_auth_app: Optional[bool] = False,
|
||||
base_url: Optional[str] = None,
|
||||
@@ -65,11 +63,6 @@ def create_app(daisyui: Optional[bool] = True,
|
||||
Script(src="/myfasthtml/tailwindcss-browser@4.js"),
|
||||
]
|
||||
|
||||
if vis:
|
||||
hdrs += [
|
||||
Script(src="/myfasthtml/vis-network.min.js"),
|
||||
]
|
||||
|
||||
beforeware = create_auth_beforeware() if protect_routes else None
|
||||
app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs)
|
||||
|
||||
@@ -103,8 +96,8 @@ def create_app(daisyui: Optional[bool] = True,
|
||||
if mount_auth_app:
|
||||
# Setup authentication routes
|
||||
setup_auth_routes(app, rt, base_url=base_url)
|
||||
|
||||
|
||||
# create the AuthProxy instance
|
||||
AuthProxy(RootInstance, base_url) # using the auto register mechanism to expose it
|
||||
AuthProxy(base_url) # using the auto register mechanism to expose it
|
||||
|
||||
return app, rt
|
||||
|
||||
@@ -6,8 +6,6 @@ from fastcore.basics import NotStr
|
||||
from myfasthtml.core.utils import quoted_str
|
||||
from myfasthtml.test.testclient import MyFT
|
||||
|
||||
MISSING_ATTR = "** MISSING **"
|
||||
|
||||
|
||||
class Predicate:
|
||||
def __init__(self, value):
|
||||
@@ -116,18 +114,6 @@ class AttributeForbidden(ChildrenPredicate):
|
||||
return element
|
||||
|
||||
|
||||
class TestObject:
|
||||
def __init__(self, cls, **kwargs):
|
||||
self.cls = cls
|
||||
self.attrs = kwargs
|
||||
|
||||
|
||||
class TestCommand(TestObject):
|
||||
def __init__(self, name, **kwargs):
|
||||
super().__init__("Command", **kwargs)
|
||||
self.attrs = {"name": name} | kwargs # name should be first
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoNotCheck:
|
||||
desc: str = None
|
||||
@@ -201,16 +187,6 @@ class ErrorOutput:
|
||||
|
||||
self.indent = self.indent[:-2]
|
||||
self._add_to_output(")")
|
||||
|
||||
elif isinstance(self.expected, TestObject):
|
||||
cls = _mytype(self.element)
|
||||
attrs = {attr_name: _mygetattr(self.element, attr_name) for attr_name in self.expected.attrs}
|
||||
self._add_to_output(f"({cls} {_str_attrs(attrs)})")
|
||||
# Try to show where the differences are
|
||||
error_str = self._detect_error_2(self.element, self.expected)
|
||||
if error_str:
|
||||
self._add_to_output(error_str)
|
||||
|
||||
else:
|
||||
self._add_to_output(str(self.element))
|
||||
# Try to show where the differences are
|
||||
@@ -229,7 +205,7 @@ class ErrorOutput:
|
||||
|
||||
if hasattr(element, "tag"):
|
||||
# the attributes are compared to the expected element
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in
|
||||
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())
|
||||
@@ -252,7 +228,7 @@ class ErrorOutput:
|
||||
def _detect_error(self, element, expected):
|
||||
if hasattr(expected, "tag") and hasattr(element, "tag"):
|
||||
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in expected.attrs}
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in expected.attrs}
|
||||
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
|
||||
not self._matches(attr_value, expected.attrs[attr_name])]
|
||||
if attrs_in_error:
|
||||
@@ -266,35 +242,6 @@ class ErrorOutput:
|
||||
|
||||
return None
|
||||
|
||||
def _detect_error_2(self, element, expected):
|
||||
"""
|
||||
Too lazy to refactor original _detect_error
|
||||
:param element:
|
||||
:param expected:
|
||||
:return:
|
||||
"""
|
||||
if hasattr(expected, "tag") or isinstance(expected, TestObject):
|
||||
element_cls = _mytype(element)
|
||||
expected_cls = _mytype(expected)
|
||||
str_tag_error = (" " if self._matches(element_cls, expected_cls) else "^") * len(element_cls)
|
||||
|
||||
element_attrs = {attr_name: _mygetattr(element, attr_name) for attr_name in expected.attrs}
|
||||
expected_attrs = {attr_name: _mygetattr(expected, attr_name) for attr_name in expected.attrs}
|
||||
attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
|
||||
not self._matches(attr_value, expected_attrs[attr_name])}
|
||||
str_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||
for name, value in element_attrs.items())
|
||||
if str_attrs_error.strip() or str_tag_error.strip():
|
||||
return f" {str_tag_error} {str_attrs_error}"
|
||||
else:
|
||||
return None
|
||||
|
||||
else:
|
||||
if not self._matches(element, expected):
|
||||
return len(str(element)) * "^"
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _matches(element, expected):
|
||||
if element == expected:
|
||||
@@ -400,34 +347,6 @@ def matches(actual, expected, path=""):
|
||||
# set the path
|
||||
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
|
||||
|
||||
if isinstance(expected, TestObject):
|
||||
assert _mytype(actual) == _mytype(expected), _error_msg("The types are different: ",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
for attr, value in expected.attrs.items():
|
||||
assert hasattr(actual, attr), _error_msg(f"'{attr}' is not found in Actual.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
try:
|
||||
matches(getattr(actual, attr), value)
|
||||
except AssertionError as e:
|
||||
match = re.search(r"Error : (.+?)\n", str(e))
|
||||
if match:
|
||||
assert False, _error_msg(f"{match.group(1)} for '{attr}':",
|
||||
_actual=getattr(actual, attr),
|
||||
_expected=value)
|
||||
assert False, _error_msg(f"The values are different for '{attr}': ",
|
||||
_actual=getattr(actual, attr),
|
||||
_expected=value)
|
||||
return True
|
||||
|
||||
if isinstance(expected, Predicate):
|
||||
assert expected.validate(actual), \
|
||||
_error_msg(f"The condition '{expected}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
|
||||
_error_msg("The types are different: ", _actual=actual, _expected=expected)
|
||||
|
||||
@@ -440,14 +359,6 @@ def matches(actual, expected, path=""):
|
||||
for actual_child, expected_child in zip(actual, expected):
|
||||
assert matches(actual_child, expected_child, path=path)
|
||||
|
||||
elif isinstance(expected, dict):
|
||||
if len(actual) < len(expected):
|
||||
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
for k, v in expected.items():
|
||||
assert matches(actual[k], v, path=f"{path}[{k}={v}]")
|
||||
|
||||
elif isinstance(expected, NotStr):
|
||||
to_compare = actual.s.lstrip('\n').lstrip()
|
||||
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
|
||||
@@ -493,88 +404,9 @@ def matches(actual, expected, path=""):
|
||||
for actual_child, expected_child in zip(actual.children, expected_children):
|
||||
assert matches(actual_child, expected_child, path=path)
|
||||
|
||||
|
||||
else:
|
||||
assert actual == expected, _error_msg("The values are different",
|
||||
assert actual == expected, _error_msg("The values are different: ",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def find(ft, expected):
|
||||
res = []
|
||||
|
||||
def _type(x):
|
||||
return type(x)
|
||||
|
||||
def _same(_ft, _expected):
|
||||
if _ft == _expected:
|
||||
return True
|
||||
|
||||
if _ft.tag != _expected.tag:
|
||||
return False
|
||||
|
||||
for attr in _expected.attrs:
|
||||
if attr not in _ft.attrs or _ft.attrs[attr] != _expected.attrs[attr]:
|
||||
return False
|
||||
|
||||
for expected_child in _expected.children:
|
||||
for ft_child in _ft.children:
|
||||
if _same(ft_child, expected_child):
|
||||
break
|
||||
else:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _find(current, current_expected):
|
||||
|
||||
if _type(current) != _type(current_expected):
|
||||
return []
|
||||
|
||||
if not hasattr(current, "tag"):
|
||||
return [current] if current == current_expected else []
|
||||
|
||||
_found = []
|
||||
if _same(current, current_expected):
|
||||
_found.append(current)
|
||||
|
||||
# look at the children
|
||||
for child in current.children:
|
||||
_found.extend(_find(child, current_expected))
|
||||
|
||||
return _found
|
||||
|
||||
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}'")
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def _mytype(x):
|
||||
if hasattr(x, "tag"):
|
||||
return x.tag
|
||||
if isinstance(x, TestObject):
|
||||
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
||||
return type(x).__name__
|
||||
|
||||
|
||||
def _mygetattr(x, attr):
|
||||
if hasattr(x, "attrs"):
|
||||
return x.attrs.get(attr, MISSING_ATTR)
|
||||
|
||||
if not hasattr(x, attr):
|
||||
return MISSING_ATTR
|
||||
|
||||
return getattr(x, attr, MISSING_ATTR)
|
||||
|
||||
|
||||
def _str_attrs(attrs: dict):
|
||||
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
|
||||
|
||||
class RootInstanceForTests(SingleInstance):
|
||||
def __init__(self, session):
|
||||
super().__init__(None, session, _id="TestRoot")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def session():
|
||||
@@ -21,8 +14,3 @@ def session():
|
||||
'updated_at': '2025-11-10T15:52:59.006213'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def root_instance(session):
|
||||
return RootInstanceForTests(session=session)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
from fasthtml.components import *
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
from myfasthtml.controls.TabsManager import TabsManager
|
||||
from myfasthtml.core.instances import InstancesManager
|
||||
@@ -11,12 +8,10 @@ from .conftest import session
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tabs_manager(root_instance):
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
yield TabsManager(root_instance)
|
||||
def tabs_manager(session):
|
||||
yield TabsManager(session)
|
||||
|
||||
InstancesManager.reset()
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
|
||||
class TestTabsManagerBehaviour:
|
||||
@@ -25,12 +20,11 @@ class TestTabsManagerBehaviour:
|
||||
assert from_instance_manager == tabs_manager
|
||||
|
||||
def test_i_can_add_tab(self, tabs_manager):
|
||||
tab_id = tabs_manager.add_tab("Tab1", Div("Content 1"))
|
||||
tab_id = tabs_manager.add_tab("Users", Div("Content 1"))
|
||||
|
||||
assert tab_id is not None
|
||||
assert tab_id in tabs_manager.get_state().tabs
|
||||
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Tab1"
|
||||
assert tabs_manager.get_state().tabs[tab_id]["id"] == tab_id
|
||||
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Users"
|
||||
assert tabs_manager.get_state().tabs[tab_id]["component_type"] is None # Div is not BaseInstance
|
||||
assert tabs_manager.get_state().tabs[tab_id]["component_id"] is None # Div is not BaseInstance
|
||||
assert tabs_manager.get_state().tabs_order == [tab_id]
|
||||
@@ -43,35 +37,6 @@ class TestTabsManagerBehaviour:
|
||||
assert len(tabs_manager.get_state().tabs) == 2
|
||||
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id2]
|
||||
assert tabs_manager.get_state().active_tab == tab_id2
|
||||
|
||||
def test_i_can_show_tab(self, tabs_manager):
|
||||
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
|
||||
|
||||
assert tabs_manager.get_state().active_tab == tab_id2 # last crated tab is active
|
||||
|
||||
tabs_manager.show_tab(tab_id1)
|
||||
assert tabs_manager.get_state().active_tab == tab_id1
|
||||
|
||||
def test_i_can_close_tab(self, tabs_manager):
|
||||
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
|
||||
tab_id3 = tabs_manager.add_tab("Tab3", Div("Content 3"))
|
||||
|
||||
tabs_manager.close_tab(tab_id2)
|
||||
|
||||
assert len(tabs_manager.get_state().tabs) == 2
|
||||
assert [tab_id for tab_id in tabs_manager.get_state().tabs] == [tab_id1, tab_id3]
|
||||
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id3]
|
||||
assert tabs_manager.get_state().active_tab == tab_id3 # last tab stays active
|
||||
|
||||
def test_i_still_have_an_active_tab_after_close(self, tabs_manager):
|
||||
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
|
||||
tab_id3 = tabs_manager.add_tab("Tab3", Div("Content 3"))
|
||||
|
||||
tabs_manager.close_tab(tab_id3) # close the currently active tab
|
||||
assert tabs_manager.get_state().active_tab == tab_id1 # default to the first tab
|
||||
|
||||
|
||||
class TestTabsManagerRender:
|
||||
@@ -79,23 +44,29 @@ class TestTabsManagerRender:
|
||||
res = tabs_manager.render()
|
||||
|
||||
expected = Div(
|
||||
Div(
|
||||
Div(id=f"{tabs_manager.get_id()}-controller"),
|
||||
Script(f'updateTabs("{tabs_manager.get_id()}-controller");'),
|
||||
),
|
||||
Div(
|
||||
Div(NoChildren(), id=f"{tabs_manager.get_id()}-header"),
|
||||
id=f"{tabs_manager.get_id()}-header-wrapper"
|
||||
),
|
||||
Div(
|
||||
Div(id=f"{tabs_manager.get_id()}-None-content"),
|
||||
id=f"{tabs_manager.get_id()}-content-wrapper"
|
||||
),
|
||||
Div(NoChildren(), id=f"{tabs_manager.get_id()}-header"),
|
||||
Div(id=f"{tabs_manager.get_id()}-content"),
|
||||
id=tabs_manager.get_id(),
|
||||
)
|
||||
|
||||
assert matches(res, expected)
|
||||
|
||||
def test_i_can_render_when_one_tab(self, tabs_manager):
|
||||
tabs_manager.add_tab("Users", Div("Content 1"))
|
||||
res = tabs_manager.render()
|
||||
|
||||
expected = Div(
|
||||
Div(
|
||||
Button(),
|
||||
id=f"{tabs_manager.get_id()}-header"
|
||||
),
|
||||
Div(
|
||||
Div("Content 1")
|
||||
),
|
||||
id=tabs_manager.get_id(),
|
||||
)
|
||||
assert matches(res, expected)
|
||||
|
||||
def test_i_can_render_when_multiple_tabs(self, tabs_manager):
|
||||
tabs_manager.add_tab("Users1", Div("Content 1"))
|
||||
tabs_manager.add_tab("Users2", Div("Content 2"))
|
||||
@@ -104,22 +75,13 @@ class TestTabsManagerRender:
|
||||
|
||||
expected = Div(
|
||||
Div(
|
||||
Div(id=f"{tabs_manager.get_id()}-controller"),
|
||||
Script(f'updateTabs("{tabs_manager.get_id()}-controller");'),
|
||||
Button(),
|
||||
Button(),
|
||||
Button(),
|
||||
id=f"{tabs_manager.get_id()}-header"
|
||||
),
|
||||
Div(
|
||||
Div(
|
||||
Div(), # tab_button
|
||||
Div(), # tab_button
|
||||
Div(), # tab_button
|
||||
id=f"{tabs_manager.get_id()}-header"
|
||||
),
|
||||
id=f"{tabs_manager.get_id()}-header-wrapper"
|
||||
),
|
||||
Div(
|
||||
Div("Content 3"), # active tab content
|
||||
# Lasy loading for the other contents
|
||||
id=f"{tabs_manager.get_id()}-content-wrapper"
|
||||
Div("Content 3")
|
||||
),
|
||||
id=tabs_manager.get_id(),
|
||||
)
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
"""Unit tests for TreeView component."""
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.test.matcher import matches, TestObject, TestCommand
|
||||
from .conftest import root_instance
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_db():
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
|
||||
class TestTreeviewBehaviour:
|
||||
"""Tests for TreeView behavior and logic."""
|
||||
|
||||
def test_i_can_create_tree_node_with_auto_generated_id(self):
|
||||
"""Test that TreeNode generates UUID automatically."""
|
||||
node = TreeNode(label="Test Node", type="folder")
|
||||
|
||||
assert node.id is not None
|
||||
assert isinstance(node.id, str)
|
||||
assert len(node.id) > 0
|
||||
|
||||
def test_i_can_create_tree_node_with_default_values(self):
|
||||
"""Test that TreeNode has correct default values."""
|
||||
node = TreeNode()
|
||||
|
||||
assert node.label == ""
|
||||
assert node.type == "default"
|
||||
assert node.parent is None
|
||||
assert node.children == []
|
||||
|
||||
def test_i_can_initialize_tree_view_state(self, root_instance):
|
||||
"""Test that TreeViewState initializes with default values."""
|
||||
tree_view = TreeView(root_instance)
|
||||
state = tree_view._state
|
||||
|
||||
assert isinstance(state.items, dict)
|
||||
assert len(state.items) == 0
|
||||
assert state.opened == []
|
||||
assert state.selected is None
|
||||
assert state.editing is None
|
||||
assert state.icon_config == {}
|
||||
|
||||
def test_i_can_create_empty_treeview(self, root_instance):
|
||||
"""Test creating an empty TreeView."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
assert tree_view is not None
|
||||
assert len(tree_view._state.items) == 0
|
||||
|
||||
def test_i_can_add_node_to_treeview(self, root_instance):
|
||||
"""Test adding a root node to the TreeView."""
|
||||
tree_view = TreeView(root_instance)
|
||||
node = TreeNode(label="Root Node", type="folder")
|
||||
|
||||
tree_view.add_node(node)
|
||||
|
||||
assert node.id in tree_view._state.items
|
||||
assert tree_view._state.items[node.id].label == "Root Node"
|
||||
assert tree_view._state.items[node.id].parent is None
|
||||
|
||||
def test_i_can_add_child_node(self, root_instance):
|
||||
"""Test adding a child node to a parent."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
|
||||
assert child.id in tree_view._state.items
|
||||
assert child.id in parent.children
|
||||
assert child.parent == parent.id
|
||||
|
||||
def test_i_can_set_icon_config(self, root_instance):
|
||||
"""Test setting icon configuration."""
|
||||
tree_view = TreeView(root_instance)
|
||||
config = {
|
||||
"folder": "fluent.folder",
|
||||
"file": "fluent.document"
|
||||
}
|
||||
|
||||
tree_view.set_icon_config(config)
|
||||
|
||||
assert tree_view._state.icon_config == config
|
||||
assert tree_view._state.icon_config["folder"] == "fluent.folder"
|
||||
|
||||
def test_i_can_toggle_node(self, root_instance):
|
||||
"""Test expand/collapse a node."""
|
||||
tree_view = TreeView(root_instance)
|
||||
node = TreeNode(label="Node", type="folder")
|
||||
tree_view.add_node(node)
|
||||
|
||||
# Initially closed
|
||||
assert node.id not in tree_view._state.opened
|
||||
|
||||
# Toggle to open
|
||||
tree_view._toggle_node(node.id)
|
||||
assert node.id in tree_view._state.opened
|
||||
|
||||
# Toggle to close
|
||||
tree_view._toggle_node(node.id)
|
||||
assert node.id not in tree_view._state.opened
|
||||
|
||||
def test_i_can_expand_all_nodes(self, root_instance):
|
||||
"""Test that expand_all opens all nodes with children."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Create hierarchy: root -> child1 -> grandchild
|
||||
# -> child2 (leaf)
|
||||
root = TreeNode(label="Root", type="folder")
|
||||
child1 = TreeNode(label="Child 1", type="folder")
|
||||
grandchild = TreeNode(label="Grandchild", type="file")
|
||||
child2 = TreeNode(label="Child 2", type="file")
|
||||
|
||||
tree_view.add_node(root)
|
||||
tree_view.add_node(child1, parent_id=root.id)
|
||||
tree_view.add_node(grandchild, parent_id=child1.id)
|
||||
tree_view.add_node(child2, parent_id=root.id)
|
||||
|
||||
# Initially all closed
|
||||
assert len(tree_view._state.opened) == 0
|
||||
|
||||
# Expand all
|
||||
tree_view.expand_all()
|
||||
|
||||
# Nodes with children should be opened
|
||||
assert root.id in tree_view._state.opened
|
||||
assert child1.id in tree_view._state.opened
|
||||
|
||||
# Leaf nodes should not be in opened list
|
||||
assert grandchild.id not in tree_view._state.opened
|
||||
assert child2.id not in tree_view._state.opened
|
||||
|
||||
def test_i_can_select_node(self, root_instance):
|
||||
"""Test selecting a node."""
|
||||
tree_view = TreeView(root_instance)
|
||||
node = TreeNode(label="Node", type="folder")
|
||||
tree_view.add_node(node)
|
||||
|
||||
tree_view._select_node(node.id)
|
||||
|
||||
assert tree_view._state.selected == node.id
|
||||
|
||||
def test_i_can_start_rename_node(self, root_instance):
|
||||
"""Test starting rename mode for a node."""
|
||||
tree_view = TreeView(root_instance)
|
||||
node = TreeNode(label="Old Name", type="folder")
|
||||
tree_view.add_node(node)
|
||||
|
||||
tree_view._start_rename(node.id)
|
||||
|
||||
assert tree_view._state.editing == node.id
|
||||
|
||||
def test_i_can_save_rename_node(self, root_instance):
|
||||
"""Test saving renamed node with new label."""
|
||||
tree_view = TreeView(root_instance)
|
||||
node = TreeNode(label="Old Name", type="folder")
|
||||
tree_view.add_node(node)
|
||||
tree_view._start_rename(node.id)
|
||||
|
||||
tree_view._save_rename(node.id, "New Name")
|
||||
|
||||
assert tree_view._state.items[node.id].label == "New Name"
|
||||
assert tree_view._state.editing is None
|
||||
|
||||
def test_i_can_cancel_rename_node(self, root_instance):
|
||||
"""Test canceling rename operation."""
|
||||
tree_view = TreeView(root_instance)
|
||||
node = TreeNode(label="Name", type="folder")
|
||||
tree_view.add_node(node)
|
||||
tree_view._start_rename(node.id)
|
||||
|
||||
tree_view._cancel_rename()
|
||||
|
||||
assert tree_view._state.editing is None
|
||||
assert tree_view._state.items[node.id].label == "Name"
|
||||
|
||||
def test_i_can_delete_leaf_node(self, root_instance):
|
||||
"""Test deleting a node without children."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
|
||||
# Delete child (leaf node)
|
||||
tree_view._delete_node(child.id)
|
||||
|
||||
assert child.id not in tree_view._state.items
|
||||
assert child.id not in parent.children
|
||||
|
||||
def test_i_can_add_sibling_node(self, root_instance):
|
||||
"""Test adding a sibling node."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child1 = TreeNode(label="Child 1", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child1, parent_id=parent.id)
|
||||
|
||||
# Add sibling to child1
|
||||
tree_view._add_sibling(child1.id, new_label="Child 2")
|
||||
|
||||
assert len(parent.children) == 2
|
||||
# Sibling should be after child1
|
||||
assert parent.children.index(child1.id) < len(parent.children) - 1
|
||||
|
||||
def test_i_cannot_delete_node_with_children(self, root_instance):
|
||||
"""Test that deleting a node with children raises an error."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
|
||||
# Try to delete parent (has children)
|
||||
with pytest.raises(ValueError, match="Cannot delete node.*with children"):
|
||||
tree_view._delete_node(parent.id)
|
||||
|
||||
def test_i_cannot_add_sibling_to_root(self, root_instance):
|
||||
"""Test that adding sibling to root node raises an error."""
|
||||
tree_view = TreeView(root_instance)
|
||||
root = TreeNode(label="Root", type="folder")
|
||||
tree_view.add_node(root)
|
||||
|
||||
# Try to add sibling to root (no parent)
|
||||
with pytest.raises(ValueError, match="Cannot add sibling to root node"):
|
||||
tree_view._add_sibling(root.id)
|
||||
|
||||
def test_i_cannot_select_nonexistent_node(self, root_instance):
|
||||
"""Test that selecting a nonexistent node raises an error."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Try to select node that doesn't exist
|
||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
||||
tree_view._select_node("nonexistent_id")
|
||||
|
||||
def test_add_node_prevents_duplicate_children(self, root_instance):
|
||||
"""Test that add_node prevents adding duplicate child IDs."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
|
||||
# Try to add the same child again
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
|
||||
# Child should appear only once in parent's children list
|
||||
assert parent.children.count(child.id) == 1
|
||||
|
||||
def test_sibling_is_inserted_at_correct_position(self, root_instance):
|
||||
"""Test that _add_sibling inserts sibling exactly after reference node."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child1 = TreeNode(label="Child 1", type="file")
|
||||
child3 = TreeNode(label="Child 3", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child1, parent_id=parent.id)
|
||||
tree_view.add_node(child3, parent_id=parent.id)
|
||||
|
||||
# Add sibling after child1
|
||||
tree_view._add_sibling(child1.id, new_label="Child 2")
|
||||
|
||||
# Get the newly added sibling
|
||||
sibling_id = parent.children[1]
|
||||
|
||||
# Verify order: child1, sibling (child2), child3
|
||||
assert parent.children[0] == child1.id
|
||||
assert tree_view._state.items[sibling_id].label == "Child 2"
|
||||
assert parent.children[2] == child3.id
|
||||
assert len(parent.children) == 3
|
||||
|
||||
def test_add_child_auto_expands_parent(self, root_instance):
|
||||
"""Test that _add_child automatically expands the parent node."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
|
||||
# Parent should not be expanded initially
|
||||
assert parent.id not in tree_view._state.opened
|
||||
|
||||
# Add child
|
||||
tree_view._add_child(parent.id, new_label="Child")
|
||||
|
||||
# Parent should now be expanded
|
||||
assert parent.id in tree_view._state.opened
|
||||
|
||||
def test_i_cannot_add_child_to_nonexistent_parent(self, root_instance):
|
||||
"""Test that adding child to nonexistent parent raises error."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Try to add child to parent that doesn't exist
|
||||
with pytest.raises(ValueError, match="Parent node.*does not exist"):
|
||||
tree_view._add_child("nonexistent_parent_id")
|
||||
|
||||
def test_delete_node_clears_selection_if_selected(self, root_instance):
|
||||
"""Test that deleting a selected node clears the selection."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
|
||||
# Select the child
|
||||
tree_view._select_node(child.id)
|
||||
assert tree_view._state.selected == child.id
|
||||
|
||||
# Delete the selected child
|
||||
tree_view._delete_node(child.id)
|
||||
|
||||
# Selection should be cleared
|
||||
assert tree_view._state.selected is None
|
||||
|
||||
def test_delete_node_removes_from_opened_if_expanded(self, root_instance):
|
||||
"""Test that deleting an expanded node removes it from opened list."""
|
||||
tree_view = TreeView(root_instance)
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
|
||||
# Expand the parent
|
||||
tree_view._toggle_node(parent.id)
|
||||
assert parent.id in tree_view._state.opened
|
||||
|
||||
# Delete the child (making parent a leaf)
|
||||
tree_view._delete_node(child.id)
|
||||
|
||||
# Now delete the parent (now a leaf node)
|
||||
# First remove it from root by creating a grandparent
|
||||
grandparent = TreeNode(label="Grandparent", type="folder")
|
||||
tree_view.add_node(grandparent)
|
||||
parent.parent = grandparent.id
|
||||
grandparent.children.append(parent.id)
|
||||
|
||||
tree_view._delete_node(parent.id)
|
||||
|
||||
# Parent should be removed from opened list
|
||||
assert parent.id not in tree_view._state.opened
|
||||
|
||||
def test_i_cannot_start_rename_nonexistent_node(self, root_instance):
|
||||
"""Test that starting rename on nonexistent node raises error."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Try to start rename on node that doesn't exist
|
||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
||||
tree_view._start_rename("nonexistent_id")
|
||||
|
||||
def test_i_cannot_save_rename_nonexistent_node(self, root_instance):
|
||||
"""Test that saving rename for nonexistent node raises error."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Try to save rename for node that doesn't exist
|
||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
||||
tree_view._save_rename("nonexistent_id", "New Name")
|
||||
|
||||
def test_i_cannot_add_sibling_to_nonexistent_node(self, root_instance):
|
||||
"""Test that adding sibling to nonexistent node raises error."""
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Try to add sibling to node that doesn't exist
|
||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
||||
tree_view._add_sibling("nonexistent_id")
|
||||
|
||||
|
||||
class TestTreeViewRender:
|
||||
"""Tests for TreeView HTML rendering."""
|
||||
|
||||
def test_i_can_render_empty_treeview(self, root_instance):
|
||||
"""Test that TreeView generates correct HTML structure."""
|
||||
tree_view = TreeView(root_instance)
|
||||
expected = Div(
|
||||
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
|
||||
_id=tree_view.get_id(),
|
||||
cls="mf-treeview"
|
||||
)
|
||||
|
||||
assert matches(tree_view.__ft__(), expected)
|
||||
|
||||
def test_node_action_buttons_are_rendered(self):
|
||||
"""Test that action buttons are present in rendered HTML."""
|
||||
# Signature only - implementation later
|
||||
pass
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from fasthtml.components import Button, Div
|
||||
from myutils.observable import make_observable, bind
|
||||
|
||||
from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand
|
||||
from myfasthtml.core.commands import Command, CommandsManager
|
||||
from myfasthtml.core.constants import ROUTE_ROOT, Routes
|
||||
from myfasthtml.test.matcher import matches
|
||||
|
||||
@@ -104,8 +104,8 @@ class TestCommandBind:
|
||||
assert matches(updated, expected)
|
||||
|
||||
@pytest.mark.parametrize("return_values", [
|
||||
[Div(), Div(id="id1"), "hello", Div(id="id2")], # list
|
||||
(Div(), Div(id="id1"), "hello", Div(id="id2")) # tuple
|
||||
[Div(), Div(), "hello", Div()], # list
|
||||
(Div(), Div(), "hello", Div()) # tuple
|
||||
])
|
||||
def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(self, return_values):
|
||||
"""Test that hx-swap-oob is automatically set, but not for the first."""
|
||||
@@ -157,40 +157,3 @@ class TestCommandExecute:
|
||||
|
||||
command = Command('test', 'Command description', callback_with_param)
|
||||
command.execute(client_response={"number": "10"})
|
||||
|
||||
def test_swap_oob_is_added_when_multiple_elements_are_returned(self):
|
||||
"""Test that hx-swap-oob is automatically set, but not for the first."""
|
||||
|
||||
def another_callback():
|
||||
return Div(id="first"), Div(id="second"), "hello", Div(id="third")
|
||||
|
||||
command = Command('test', 'Command description', another_callback)
|
||||
|
||||
res = command.execute()
|
||||
assert "hx-swap-oob" not in res[0].attrs
|
||||
assert res[1].attrs["hx-swap-oob"] == "true"
|
||||
assert res[3].attrs["hx-swap-oob"] == "true"
|
||||
|
||||
def test_swap_oob_is_not_added_when_there_no_id(self):
|
||||
"""Test that hx-swap-oob is automatically set, but not for the first."""
|
||||
|
||||
def another_callback():
|
||||
return Div(id="first"), Div(), "hello", Div()
|
||||
|
||||
command = Command('test', 'Command description', another_callback)
|
||||
|
||||
res = command.execute()
|
||||
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[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,7 +4,6 @@ from dataclasses import dataclass
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.dbmanager import DbManager, DbObject
|
||||
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@@ -20,14 +19,9 @@ def session():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parent(session):
|
||||
return SingleInstance(session=session, _id="test_parent_id")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_manager(parent):
|
||||
def db_manager(session):
|
||||
shutil.rmtree("TestDb", ignore_errors=True)
|
||||
db_manager_instance = DbManager(parent, root="TestDb", auto_register=False)
|
||||
db_manager_instance = DbManager(session, root="TestDb", auto_register=False)
|
||||
|
||||
yield db_manager_instance
|
||||
|
||||
@@ -38,17 +32,17 @@ def simplify(res: dict) -> dict:
|
||||
return {k: v for k, v in res.items() if not k.startswith("_")}
|
||||
|
||||
|
||||
def test_i_can_init(parent, db_manager):
|
||||
def test_i_can_init(session, db_manager):
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
with self.initializing():
|
||||
self.value: str = "hello"
|
||||
self.number: int = 42
|
||||
self.none_value: None = None
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy = DummyObject(session)
|
||||
|
||||
props = dummy._get_properties()
|
||||
|
||||
@@ -58,17 +52,17 @@ def test_i_can_init(parent, db_manager):
|
||||
assert len(history) == 1
|
||||
|
||||
|
||||
def test_i_can_init_from_dataclass(parent, db_manager):
|
||||
def test_i_can_init_from_dataclass(session, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
none_value: None = None
|
||||
|
||||
DummyObject(parent)
|
||||
DummyObject(session)
|
||||
|
||||
in_db = db_manager.load("DummyObject")
|
||||
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
||||
@@ -76,10 +70,10 @@ def test_i_can_init_from_dataclass(parent, db_manager):
|
||||
assert len(history) == 1
|
||||
|
||||
|
||||
def test_i_can_init_from_db_with(parent, db_manager):
|
||||
def test_i_can_init_from_db_with(session, db_manager):
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
with self.initializing():
|
||||
self.value: str = "hello"
|
||||
@@ -88,17 +82,17 @@ def test_i_can_init_from_db_with(parent, db_manager):
|
||||
# insert other values in db
|
||||
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy = DummyObject(session)
|
||||
|
||||
assert dummy.value == "other_value"
|
||||
assert dummy.number == 34
|
||||
|
||||
|
||||
def test_i_can_init_from_db_with_dataclass(parent, db_manager):
|
||||
def test_i_can_init_from_db_with_dataclass(session, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
@@ -106,83 +100,37 @@ def test_i_can_init_from_db_with_dataclass(parent, db_manager):
|
||||
# insert other values in db
|
||||
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy = DummyObject(session)
|
||||
|
||||
assert dummy.value == "other_value"
|
||||
assert dummy.number == 34
|
||||
|
||||
|
||||
def test_i_do_not_save_when_prefixed_by_underscore_or_ns(parent, db_manager):
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
|
||||
with self.initializing():
|
||||
self.to_save: str = "value"
|
||||
self._not_to_save: str = "value"
|
||||
self.ns_not_to_save: str = "value"
|
||||
|
||||
to_save: str = "value"
|
||||
_not_to_save: str = "value"
|
||||
ns_not_to_save: str = "value"
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy.to_save = "other_value"
|
||||
dummy.ns_not_to_save = "other_value"
|
||||
dummy._not_to_save = "other_value"
|
||||
|
||||
in_db = db_manager.load("DummyObject")
|
||||
assert in_db["to_save"] == "other_value"
|
||||
assert "_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(parent, db_manager):
|
||||
def test_db_is_updated_when_attribute_is_modified(session, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
|
||||
to_save: str = "value"
|
||||
_not_to_save: str = "value"
|
||||
ns_not_to_save: str = "value"
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy.to_save = "other_value"
|
||||
dummy.ns_not_to_save = "other_value"
|
||||
dummy._not_to_save = "other_value"
|
||||
|
||||
in_db = db_manager.load("DummyObject")
|
||||
assert in_db["to_save"] == "other_value"
|
||||
assert "_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(parent, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy = DummyObject(session)
|
||||
dummy.value = "other_value"
|
||||
|
||||
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 42}
|
||||
|
||||
|
||||
def test_i_do_not_save_in_db_when_value_is_the_same(parent, db_manager):
|
||||
def test_i_do_not_save_in_db_when_value_is_the_same(session, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy = DummyObject(session)
|
||||
dummy.value = "other_value"
|
||||
in_db_1 = db_manager.load("DummyObject")
|
||||
|
||||
@@ -192,16 +140,16 @@ def test_i_do_not_save_in_db_when_value_is_the_same(parent, db_manager):
|
||||
assert in_db_1["__parent__"] == in_db_2["__parent__"]
|
||||
|
||||
|
||||
def test_i_can_update(parent, db_manager):
|
||||
def test_i_can_update(session, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy = DummyObject(session)
|
||||
clone = dummy.copy()
|
||||
|
||||
clone.number = 34
|
||||
@@ -213,52 +161,54 @@ def test_i_can_update(parent, db_manager):
|
||||
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
|
||||
|
||||
|
||||
def test_forbidden_attributes_are_not_the_copy(parent, db_manager):
|
||||
def test_forbidden_attributes_are_not_the_copy(session, db_manager):
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
with self.initializing():
|
||||
self.value: str = "hello"
|
||||
self.number: int = 42
|
||||
self.none_value: None = None
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy = DummyObject(session)
|
||||
|
||||
clone = dummy.copy()
|
||||
|
||||
for k in DbObject._forbidden_attrs:
|
||||
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||
|
||||
|
||||
def test_forbidden_attributes_are_not_the_copy_for_dataclass(parent, db_manager):
|
||||
def test_forbidden_attributes_are_not_the_copy_for_dataclass(session, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
none_value: None = None
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy = DummyObject(session)
|
||||
|
||||
clone = dummy.copy()
|
||||
|
||||
for k in DbObject._forbidden_attrs:
|
||||
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
|
||||
|
||||
|
||||
def test_i_cannot_update_a_forbidden_attribute(parent, db_manager):
|
||||
def test_i_cannot_update_a_forbidden_attribute(session, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
def __init__(self, sess: dict):
|
||||
super().__init__(sess, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
none_value: None = None
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy = DummyObject(session)
|
||||
|
||||
dummy.update(_owner="other_value")
|
||||
dummy.update(_session="other_value")
|
||||
|
||||
assert dummy._owner is parent
|
||||
assert dummy._session == session
|
||||
|
||||
@@ -1,399 +0,0 @@
|
||||
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
|
||||
@@ -1,105 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from myfasthtml.core.matching_utils import fuzzy_matching, subsequence_matching
|
||||
|
||||
|
||||
class TestFuzzyMatching:
|
||||
def test_i_can_find_exact_match_with_fuzzy(self):
|
||||
# Exact match should always pass
|
||||
choices = ["hello"]
|
||||
result = fuzzy_matching("hello", choices)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "hello"
|
||||
|
||||
def test_i_can_find_close_match_with_fuzzy(self):
|
||||
# "helo.txt" should match "hello.txt" with high similarity
|
||||
choices = ["hello"]
|
||||
result = fuzzy_matching("helo", choices, similarity_threshold=0.7)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "hello"
|
||||
|
||||
def test_i_cannot_find_dissimilar_match_with_fuzzy(self):
|
||||
# "world.txt" should not match "hello.txt"
|
||||
choices = ["hello"]
|
||||
result = fuzzy_matching("world", choices, similarity_threshold=0.7)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_i_can_sort_by_similarity_in_fuzzy(self):
|
||||
# hello has a higher similarity than helo
|
||||
choices = [
|
||||
"hello",
|
||||
"helo",
|
||||
]
|
||||
result = fuzzy_matching("hello", choices, similarity_threshold=0.7)
|
||||
assert result == ["hello", "helo"]
|
||||
|
||||
|
||||
def test_i_can_find_on_object(self):
|
||||
@dataclass
|
||||
class DummyObject:
|
||||
value: str
|
||||
id: str
|
||||
|
||||
choices = [
|
||||
DummyObject("helo", "1"),
|
||||
DummyObject("hello", "2"),
|
||||
DummyObject("xyz", "3"),
|
||||
]
|
||||
result = fuzzy_matching("hello", choices, get_attr=lambda x: x.value)
|
||||
assert len(result) == 2
|
||||
assert result == [DummyObject("hello", "2"), DummyObject("helo", "1")]
|
||||
|
||||
|
||||
class TestSubsequenceMatching:
|
||||
def test_i_can_match_subsequence_simple(self):
|
||||
# "abg" should match "AlphaBetaGamma"
|
||||
choices = ["AlphaBetaGamma"]
|
||||
result = subsequence_matching("abg", choices)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "AlphaBetaGamma"
|
||||
|
||||
def test_i_can_match_subsequence_simple_case_insensitive(self):
|
||||
# "abg" should match "alphabetagamma"
|
||||
choices = ["alphabetagamma"]
|
||||
result = subsequence_matching("abg", choices)
|
||||
assert len(result) == 1
|
||||
assert result[0] == "alphabetagamma"
|
||||
|
||||
def test_i_cannot_match_wrong_order_subsequence(self):
|
||||
# the order is wrong
|
||||
choices = ["AlphaBetaGamma"]
|
||||
result = subsequence_matching("gba", choices)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_i_can_match_multiple_documents_subsequence(self):
|
||||
# "abg" should match both filenames, but "AlphaBetaGamma" has a higher score
|
||||
choices = [
|
||||
"AlphaBetaGamma",
|
||||
"HalleBerryIsGone",
|
||||
]
|
||||
result = subsequence_matching("abg", choices)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "AlphaBetaGamma"
|
||||
assert result[1] == "HalleBerryIsGone"
|
||||
|
||||
def test_i_cannot_match_unrelated_subsequence(self):
|
||||
# "xyz" should not match any file
|
||||
choices = ["AlphaBetaGamma"]
|
||||
result = subsequence_matching("xyz", choices)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_i_can_match_on_object(self):
|
||||
@dataclass
|
||||
class DummyObject:
|
||||
value: str
|
||||
id: str
|
||||
|
||||
choices = [
|
||||
DummyObject("HalleBerryIsGone", "1"),
|
||||
DummyObject("AlphaBetaGamma", "2"),
|
||||
DummyObject("xyz", "3"),
|
||||
]
|
||||
|
||||
result = subsequence_matching("abg", choices, get_attr=lambda x: x.value)
|
||||
assert len(result) == 2
|
||||
assert result == [DummyObject("AlphaBetaGamma", "2"), DummyObject("HalleBerryIsGone", "1")]
|
||||
@@ -1,648 +0,0 @@
|
||||
from myfasthtml.core.network_utils import from_nested_dict, from_tree_with_metadata, from_parent_child_list
|
||||
|
||||
|
||||
class TestFromNestedDict:
|
||||
def test_i_can_convert_single_root_node(self):
|
||||
"""Test conversion of a single root node without children."""
|
||||
trees = [{"root": {}}]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0] == {"id": 1, "label": "root"}
|
||||
assert len(edges) == 0
|
||||
|
||||
def test_i_can_convert_tree_with_one_level_children(self):
|
||||
"""Test conversion with direct children, verifying edge creation."""
|
||||
trees = [{"root": {"child1": {}, "child2": {}}}]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert len(nodes) == 3
|
||||
assert nodes[0] == {"id": 1, "label": "root"}
|
||||
assert nodes[1] == {"id": 2, "label": "child1"}
|
||||
assert nodes[2] == {"id": 3, "label": "child2"}
|
||||
|
||||
assert len(edges) == 2
|
||||
assert {"from": 1, "to": 2} in edges
|
||||
assert {"from": 1, "to": 3} in edges
|
||||
|
||||
def test_i_can_convert_tree_with_multiple_levels(self):
|
||||
"""Test recursive conversion with multiple levels of nesting."""
|
||||
trees = [
|
||||
{
|
||||
"root": {
|
||||
"child1": {
|
||||
"grandchild1": {},
|
||||
"grandchild2": {}
|
||||
},
|
||||
"child2": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert len(nodes) == 5
|
||||
assert len(edges) == 4
|
||||
|
||||
# Verify hierarchy
|
||||
assert {"from": 1, "to": 2} in edges # root -> child1
|
||||
assert {"from": 1, "to": 5} in edges # root -> child2
|
||||
assert {"from": 2, "to": 3} in edges # child1 -> grandchild1
|
||||
assert {"from": 2, "to": 4} in edges # child1 -> grandchild2
|
||||
|
||||
def test_i_can_generate_auto_incremented_ids(self):
|
||||
"""Test that IDs start at 1 and increment correctly."""
|
||||
trees = [{"a": {"b": {"c": {}}}}]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
ids = [node["id"] for node in nodes]
|
||||
assert ids == [1, 2, 3]
|
||||
|
||||
def test_i_can_use_dict_keys_as_labels(self):
|
||||
"""Test that dictionary keys become node labels."""
|
||||
trees = [{"RootNode": {"ChildNode": {}}}]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert nodes[0]["label"] == "RootNode"
|
||||
assert nodes[1]["label"] == "ChildNode"
|
||||
|
||||
def test_i_can_convert_empty_list(self):
|
||||
"""Test that empty list returns empty nodes and edges."""
|
||||
trees = []
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
def test_i_can_convert_multiple_root_nodes(self):
|
||||
"""Test conversion with multiple independent trees."""
|
||||
trees = [
|
||||
{"root1": {"child1": {}}},
|
||||
{"root2": {"child2": {}}}
|
||||
]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert len(nodes) == 4
|
||||
assert nodes[0] == {"id": 1, "label": "root1"}
|
||||
assert nodes[1] == {"id": 2, "label": "child1"}
|
||||
assert nodes[2] == {"id": 3, "label": "root2"}
|
||||
assert nodes[3] == {"id": 4, "label": "child2"}
|
||||
|
||||
# Verify edges connect within trees, not across
|
||||
assert len(edges) == 2
|
||||
assert {"from": 1, "to": 2} in edges
|
||||
assert {"from": 3, "to": 4} in edges
|
||||
|
||||
def test_i_can_maintain_id_sequence_across_multiple_trees(self):
|
||||
"""Test that ID counter continues across multiple trees."""
|
||||
trees = [
|
||||
{"tree1": {}},
|
||||
{"tree2": {}},
|
||||
{"tree3": {}}
|
||||
]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
ids = [node["id"] for node in nodes]
|
||||
assert ids == [1, 2, 3]
|
||||
|
||||
|
||||
class TestFromTreeWithMetadata:
|
||||
def test_i_can_convert_single_node_with_metadata(self):
|
||||
"""Test basic conversion with explicit id and label."""
|
||||
trees = [{"id": "root", "label": "Root Node"}]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0] == {"id": "root", "label": "Root Node"}
|
||||
assert len(edges) == 0
|
||||
|
||||
def test_i_can_preserve_string_ids_from_metadata(self):
|
||||
"""Test that string IDs from the tree are preserved."""
|
||||
trees = [
|
||||
{
|
||||
"id": "root_id",
|
||||
"label": "Root",
|
||||
"children": [
|
||||
{"id": "child_id", "label": "Child"}
|
||||
]
|
||||
}
|
||||
]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert nodes[0]["id"] == "root_id"
|
||||
assert nodes[1]["id"] == "child_id"
|
||||
assert edges[0] == {"from": "root_id", "to": "child_id"}
|
||||
|
||||
def test_i_can_auto_increment_when_id_is_missing(self):
|
||||
"""Test fallback to auto-increment when ID is not provided."""
|
||||
trees = [
|
||||
{
|
||||
"label": "Root",
|
||||
"children": [
|
||||
{"label": "Child1"},
|
||||
{"id": "child2_id", "label": "Child2"}
|
||||
]
|
||||
}
|
||||
]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert nodes[0]["id"] == 1 # auto-incremented
|
||||
assert nodes[1]["id"] == 2 # auto-incremented
|
||||
assert nodes[2]["id"] == "child2_id" # preserved
|
||||
|
||||
def test_i_can_convert_tree_with_children(self):
|
||||
"""Test handling of children list."""
|
||||
trees = [
|
||||
{
|
||||
"id": "root",
|
||||
"label": "Root",
|
||||
"children": [
|
||||
{
|
||||
"id": "child1",
|
||||
"label": "Child 1",
|
||||
"children": [
|
||||
{"id": "grandchild", "label": "Grandchild"}
|
||||
]
|
||||
},
|
||||
{"id": "child2", "label": "Child 2"}
|
||||
]
|
||||
}
|
||||
]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert len(nodes) == 4
|
||||
assert len(edges) == 3
|
||||
assert {"from": "root", "to": "child1"} in edges
|
||||
assert {"from": "root", "to": "child2"} in edges
|
||||
assert {"from": "child1", "to": "grandchild"} in edges
|
||||
|
||||
def test_i_can_use_custom_id_getter(self):
|
||||
"""Test custom callback for extracting node ID."""
|
||||
trees = [
|
||||
{
|
||||
"node_id": "custom_root",
|
||||
"label": "Root"
|
||||
}
|
||||
]
|
||||
|
||||
def custom_id_getter(node):
|
||||
return node.get("node_id")
|
||||
|
||||
nodes, edges = from_tree_with_metadata(
|
||||
trees,
|
||||
id_getter=custom_id_getter
|
||||
)
|
||||
|
||||
assert nodes[0]["id"] == "custom_root"
|
||||
|
||||
def test_i_can_use_custom_label_getter(self):
|
||||
"""Test custom callback for extracting node label."""
|
||||
trees = [
|
||||
{
|
||||
"id": "root",
|
||||
"name": "Custom Label"
|
||||
}
|
||||
]
|
||||
|
||||
def custom_label_getter(node):
|
||||
return node.get("name", "")
|
||||
|
||||
nodes, edges = from_tree_with_metadata(
|
||||
trees,
|
||||
label_getter=custom_label_getter
|
||||
)
|
||||
|
||||
assert nodes[0]["label"] == "Custom Label"
|
||||
|
||||
def test_i_can_use_custom_children_getter(self):
|
||||
"""Test custom callback for extracting children."""
|
||||
trees = [
|
||||
{
|
||||
"id": "root",
|
||||
"label": "Root",
|
||||
"kids": [
|
||||
{"id": "child", "label": "Child"}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
def custom_children_getter(node):
|
||||
return node.get("kids", [])
|
||||
|
||||
nodes, edges = from_tree_with_metadata(
|
||||
trees,
|
||||
children_getter=custom_children_getter
|
||||
)
|
||||
|
||||
assert len(nodes) == 2
|
||||
assert nodes[1]["id"] == "child"
|
||||
|
||||
def test_i_can_handle_missing_label_with_default(self):
|
||||
"""Test that missing label returns empty string."""
|
||||
trees = [{"id": "root"}]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert nodes[0]["label"] == ""
|
||||
|
||||
def test_i_can_handle_missing_children_with_default(self):
|
||||
"""Test that missing children returns empty list (no children processed)."""
|
||||
trees = [{"id": "root", "label": "Root"}]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert len(nodes) == 1
|
||||
assert len(edges) == 0
|
||||
|
||||
def test_i_can_convert_multiple_root_trees(self):
|
||||
"""Test conversion with multiple independent trees with metadata."""
|
||||
trees = [
|
||||
{
|
||||
"id": "root1",
|
||||
"label": "Root 1",
|
||||
"children": [
|
||||
{"id": "child1", "label": "Child 1"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "root2",
|
||||
"label": "Root 2",
|
||||
"children": [
|
||||
{"id": "child2", "label": "Child 2"}
|
||||
]
|
||||
}
|
||||
]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert len(nodes) == 4
|
||||
assert nodes[0]["id"] == "root1"
|
||||
assert nodes[1]["id"] == "child1"
|
||||
assert nodes[2]["id"] == "root2"
|
||||
assert nodes[3]["id"] == "child2"
|
||||
|
||||
# Verify edges connect within trees, not across
|
||||
assert len(edges) == 2
|
||||
assert {"from": "root1", "to": "child1"} in edges
|
||||
assert {"from": "root2", "to": "child2"} in edges
|
||||
|
||||
def test_i_can_maintain_id_counter_across_multiple_trees_when_missing_ids(self):
|
||||
"""Test that auto-increment counter continues across multiple trees."""
|
||||
trees = [
|
||||
{"label": "Tree1"},
|
||||
{"label": "Tree2"},
|
||||
{"label": "Tree3"}
|
||||
]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert nodes[0]["id"] == 1
|
||||
assert nodes[1]["id"] == 2
|
||||
assert nodes[2]["id"] == 3
|
||||
|
||||
def test_i_can_convert_empty_list(self):
|
||||
"""Test that empty list returns empty nodes and edges."""
|
||||
trees = []
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
|
||||
class TestFromParentChildList:
|
||||
def test_i_can_convert_single_root_node_without_parent(self):
|
||||
"""Test conversion of a single root node without parent."""
|
||||
items = [{"id": "root", "label": "Root"}]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0] == {'color': '#ff9999', 'id': 'root', 'label': 'Root'}
|
||||
assert len(edges) == 0
|
||||
|
||||
def test_i_can_convert_simple_parent_child_relationship(self):
|
||||
"""Test conversion with basic parent-child relationship."""
|
||||
items = [
|
||||
{"id": "root", "label": "Root"},
|
||||
{"id": "child", "parent": "root", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 2
|
||||
assert {'color': '#ff9999', 'id': 'root', 'label': 'Root'} in nodes
|
||||
assert {"id": "child", "label": "Child"} in nodes
|
||||
|
||||
assert len(edges) == 1
|
||||
assert edges[0] == {"from": "root", "to": "child"}
|
||||
|
||||
def test_i_can_convert_multiple_children_with_same_parent(self):
|
||||
"""Test that one parent can have multiple children."""
|
||||
items = [
|
||||
{"id": "root", "label": "Root"},
|
||||
{"id": "child1", "parent": "root", "label": "Child 1"},
|
||||
{"id": "child2", "parent": "root", "label": "Child 2"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 3
|
||||
assert len(edges) == 2
|
||||
assert {"from": "root", "to": "child1"} in edges
|
||||
assert {"from": "root", "to": "child2"} in edges
|
||||
|
||||
def test_i_can_convert_multi_level_hierarchy(self):
|
||||
"""Test conversion with multiple levels (root -> child -> grandchild)."""
|
||||
items = [
|
||||
{"id": "root", "label": "Root"},
|
||||
{"id": "child", "parent": "root", "label": "Child"},
|
||||
{"id": "grandchild", "parent": "child", "label": "Grandchild"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 3
|
||||
assert len(edges) == 2
|
||||
assert {"from": "root", "to": "child"} in edges
|
||||
assert {"from": "child", "to": "grandchild"} in edges
|
||||
|
||||
def test_i_can_handle_parent_none_as_root(self):
|
||||
"""Test that parent=None identifies a root node."""
|
||||
items = [
|
||||
{"id": "root", "parent": None, "label": "Root"},
|
||||
{"id": "child", "parent": "root", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 2
|
||||
assert len(edges) == 1
|
||||
assert edges[0] == {"from": "root", "to": "child"}
|
||||
|
||||
def test_i_can_handle_parent_empty_string_as_root(self):
|
||||
"""Test that parent='' identifies a root node."""
|
||||
items = [
|
||||
{"id": "root", "parent": "", "label": "Root"},
|
||||
{"id": "child", "parent": "root", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 2
|
||||
assert len(edges) == 1
|
||||
assert edges[0] == {"from": "root", "to": "child"}
|
||||
|
||||
def test_i_can_create_ghost_node_for_missing_parent(self):
|
||||
"""Test automatic creation of ghost node when parent doesn't exist."""
|
||||
items = [
|
||||
{"id": "child", "parent": "missing_parent", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 2
|
||||
# Find the ghost node
|
||||
ghost_node = [n for n in nodes if n["id"] == "missing_parent"][0]
|
||||
assert ghost_node is not None
|
||||
|
||||
assert len(edges) == 1
|
||||
assert edges[0] == {"from": "missing_parent", "to": "child"}
|
||||
|
||||
def test_i_can_apply_ghost_color_to_missing_parent(self):
|
||||
"""Test that ghost nodes have the default ghost color."""
|
||||
items = [
|
||||
{"id": "child", "parent": "ghost", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
|
||||
assert "color" in ghost_node
|
||||
assert ghost_node["color"] == "#cccccc"
|
||||
|
||||
def test_i_can_use_custom_ghost_color(self):
|
||||
"""Test that custom ghost_color parameter is applied."""
|
||||
items = [
|
||||
{"id": "child", "parent": "ghost", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items, ghost_color="#0000ff")
|
||||
|
||||
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
|
||||
assert ghost_node["color"] == "#0000ff"
|
||||
|
||||
def test_i_can_create_multiple_ghost_nodes(self):
|
||||
"""Test handling of multiple missing parents."""
|
||||
items = [
|
||||
{"id": "child1", "parent": "ghost1", "label": "Child 1"},
|
||||
{"id": "child2", "parent": "ghost2", "label": "Child 2"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 4 # 2 real + 2 ghost
|
||||
ghost_ids = [n["id"] for n in nodes if "color" in n]
|
||||
assert "ghost1" in ghost_ids
|
||||
assert "ghost2" in ghost_ids
|
||||
|
||||
def test_i_can_avoid_duplicate_ghost_nodes(self):
|
||||
"""Test that same missing parent creates only one ghost node."""
|
||||
items = [
|
||||
{"id": "child1", "parent": "ghost", "label": "Child 1"},
|
||||
{"id": "child2", "parent": "ghost", "label": "Child 2"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 3 # 2 real + 1 ghost
|
||||
ghost_nodes = [n for n in nodes if n["id"] == "ghost"]
|
||||
assert len(ghost_nodes) == 1
|
||||
|
||||
assert len(edges) == 2
|
||||
assert {"from": "ghost", "to": "child1"} in edges
|
||||
assert {"from": "ghost", "to": "child2"} in edges
|
||||
|
||||
def test_i_can_use_custom_id_getter(self):
|
||||
"""Test custom callback for extracting node ID."""
|
||||
items = [
|
||||
{"node_id": "root", "label": "Root"}
|
||||
]
|
||||
|
||||
def custom_id_getter(item):
|
||||
return item.get("node_id")
|
||||
|
||||
nodes, edges = from_parent_child_list(
|
||||
items,
|
||||
id_getter=custom_id_getter
|
||||
)
|
||||
|
||||
assert nodes[0]["id"] == "root"
|
||||
|
||||
def test_i_can_use_custom_label_getter(self):
|
||||
"""Test custom callback for extracting node label."""
|
||||
items = [
|
||||
{"id": "root", "name": "Custom Label"}
|
||||
]
|
||||
|
||||
def custom_label_getter(item):
|
||||
return item.get("name", "")
|
||||
|
||||
nodes, edges = from_parent_child_list(
|
||||
items,
|
||||
label_getter=custom_label_getter
|
||||
)
|
||||
|
||||
assert nodes[0]["label"] == "Custom Label"
|
||||
|
||||
def test_i_can_use_custom_parent_getter(self):
|
||||
"""Test custom callback for extracting parent ID."""
|
||||
items = [
|
||||
{"id": "root", "label": "Root"},
|
||||
{"id": "child", "parent_id": "root", "label": "Child"}
|
||||
]
|
||||
|
||||
def custom_parent_getter(item):
|
||||
return item.get("parent_id")
|
||||
|
||||
nodes, edges = from_parent_child_list(
|
||||
items,
|
||||
parent_getter=custom_parent_getter
|
||||
)
|
||||
|
||||
assert len(edges) == 1
|
||||
assert edges[0] == {"from": "root", "to": "child"}
|
||||
|
||||
def test_i_can_handle_empty_list(self):
|
||||
"""Test that empty list returns empty nodes and edges."""
|
||||
items = []
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
def test_i_can_use_id_as_label_for_ghost_nodes(self):
|
||||
"""Test that ghost nodes use their ID as label by default."""
|
||||
items = [
|
||||
{"id": "child", "parent": "ghost_parent", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
ghost_node = [n for n in nodes if n["id"] == "ghost_parent"][0]
|
||||
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]
|
||||
@@ -1,450 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
console.log("Parsing combination", combinationStr, "=>", sequence);
|
||||
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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -1,634 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -1,309 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Keyboard 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.shortcuts-list {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.shortcuts-list h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.shortcuts-list ul {
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.shortcuts-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;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Keyboard Support Test Page</h1>
|
||||
|
||||
<div class="shortcuts-list">
|
||||
<h3>📋 Configured Shortcuts (with HTMX options)</h3>
|
||||
<p><strong>Simple keys:</strong></p>
|
||||
<ul>
|
||||
<li><code>a</code> - Simple key A (POST to /test/key-a)</li>
|
||||
<li><code>esc</code> - Escape key (POST)</li>
|
||||
</ul>
|
||||
<p><strong>Simultaneous combinations with HTMX options:</strong></p>
|
||||
<ul>
|
||||
<li><code>Ctrl+S</code> - Save (POST with swap: innerHTML)</li>
|
||||
<li><code>Ctrl+C</code> - Copy (POST with target: #result, swap: outerHTML)</li>
|
||||
</ul>
|
||||
<p><strong>Sequences with various configs:</strong></p>
|
||||
<ul>
|
||||
<li><code>A B</code> - Sequence (POST with extra values: {"extra": "data"})</li>
|
||||
<li><code>A B C</code> - Triple sequence (GET request)</li>
|
||||
<li><code>shift shift</code> - Press Shift twice in sequence</li>
|
||||
</ul>
|
||||
<p><strong>Complex:</strong></p>
|
||||
<ul>
|
||||
<li><code>Ctrl+C C</code> - Ctrl+C then C alone</li>
|
||||
<li><code>Ctrl+C Ctrl+C</code> - Ctrl+C twice</li>
|
||||
</ul>
|
||||
<p><strong>Tip:</strong> Check the log to see how HTMX options (target, swap, extra values) are passed!</p>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<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;">
|
||||
<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 class="test-container">
|
||||
<h2>Test Element 1</h2>
|
||||
<div id="test-element" class="test-element" tabindex="0">
|
||||
Click me to focus, then try keyboard shortcuts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Test Element 2 (also listens to ESC and Shift Shift)</h2>
|
||||
<div id="test-element-2" class="test-element" tabindex="0">
|
||||
This element also responds to ESC and Shift Shift
|
||||
</div>
|
||||
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
|
||||
Remove Element 2 Keyboard Support
|
||||
</button>
|
||||
</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}`,
|
||||
`Focus 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');
|
||||
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_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>
|
||||
|
||||
<!-- Include keyboard support script -->
|
||||
<script src="keyboard_support.js"></script>
|
||||
|
||||
<!-- Initialize keyboard support -->
|
||||
<script>
|
||||
const combinations = {
|
||||
"a": {
|
||||
"hx-post": "/test/key-a"
|
||||
},
|
||||
"Ctrl+S": {
|
||||
"hx-post": "/test/save",
|
||||
"hx-swap": "innerHTML"
|
||||
},
|
||||
"Ctrl+C": {
|
||||
"hx-post": "/test/copy",
|
||||
"hx-target": "#result",
|
||||
"hx-swap": "outerHTML"
|
||||
},
|
||||
"A B": {
|
||||
"hx-post": "/test/sequence-ab",
|
||||
"hx-vals": {"extra": "data"}
|
||||
},
|
||||
"A B C": {
|
||||
"hx-get": "/test/sequence-abc"
|
||||
},
|
||||
"Ctrl+C C": {
|
||||
"hx-post": "/test/complex-ctrl-c-c"
|
||||
},
|
||||
"Ctrl+C Ctrl+C": {
|
||||
"hx-post": "/test/complex-ctrl-c-twice"
|
||||
},
|
||||
"shift shift": {
|
||||
"hx-post": "/test/shift-shift"
|
||||
},
|
||||
"esc": {
|
||||
"hx-post": "/test/escape"
|
||||
}
|
||||
};
|
||||
|
||||
add_keyboard_support('test-element', JSON.stringify(combinations));
|
||||
|
||||
// Add second element that also listens to ESC and shift shift
|
||||
const combinations2 = {
|
||||
"esc": {
|
||||
"hx-post": "/test/escape-element2"
|
||||
},
|
||||
"shift shift": {
|
||||
"hx-post": "/test/shift-shift-element2"
|
||||
}
|
||||
};
|
||||
|
||||
add_keyboard_support('test-element-2', JSON.stringify(combinations2));
|
||||
|
||||
// Log initial state
|
||||
logEvent('Keyboard support initialized',
|
||||
'Element 1: All shortcuts configured with HTMX options',
|
||||
'Element 2: ESC and Shift Shift (will trigger simultaneously with Element 1)',
|
||||
'Smart timeout enabled: waits 500ms only if longer sequence exists', false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,356 +0,0 @@
|
||||
<!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,42 +0,0 @@
|
||||
import pytest
|
||||
from fasthtml.components import Div, Span
|
||||
|
||||
from myfasthtml.test.matcher import find
|
||||
|
||||
|
||||
@pytest.mark.parametrize('ft, expected', [
|
||||
("hello", "hello"),
|
||||
(Div(id="id1"), Div(id="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(Div(id="id2"), id2="id1"), Div(id="id1")),
|
||||
])
|
||||
def test_i_can_find(ft, expected):
|
||||
assert find(expected, expected) == [expected]
|
||||
|
||||
|
||||
def test_find_element_by_id_in_a_list():
|
||||
a = Div(id="id1")
|
||||
b = Div(id="id2")
|
||||
c = Div(id="id3")
|
||||
|
||||
assert find([a, b, c], b) == [b]
|
||||
|
||||
|
||||
def test_i_can_find_sub_element():
|
||||
a = Div(id="id1")
|
||||
b = Div(a, id="id2")
|
||||
c = Div(b, id="id3")
|
||||
|
||||
assert find(c, a) == [a]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('ft, expected', [
|
||||
(None, Div(id="id1")),
|
||||
(Span(id="id1"), Div(id="id1")),
|
||||
(Div(id2="id1"), Div(id="id1")),
|
||||
(Div(id="id2"), Div(id="id1")),
|
||||
])
|
||||
def test_i_cannot_find(ft, expected):
|
||||
with pytest.raises(AssertionError):
|
||||
find(expected, ft)
|
||||
@@ -3,440 +3,361 @@ from fastcore.basics import NotStr
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
|
||||
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject
|
||||
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren
|
||||
from myfasthtml.test.testclient import MyFT
|
||||
|
||||
|
||||
class Dummy:
|
||||
def __init__(self, attr1, attr2=None):
|
||||
self.attr1 = attr1
|
||||
self.attr2 = attr2
|
||||
@pytest.mark.parametrize('actual, expected', [
|
||||
(None, None),
|
||||
(123, 123),
|
||||
(Div(), Div()),
|
||||
([Div(), Span()], [Div(), Span()]),
|
||||
(Div(attr1="value"), Div(attr1="value")),
|
||||
(Div(attr1="value", attr2="value"), Div(attr1="value")),
|
||||
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
|
||||
(Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))),
|
||||
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
|
||||
(Div(attr1="value"), Div(attr1=AnyValue())),
|
||||
(None, DoNotCheck()),
|
||||
(123, DoNotCheck()),
|
||||
(Div(), DoNotCheck()),
|
||||
([Div(), Span()], DoNotCheck()),
|
||||
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
|
||||
(Div(), Div(Empty())),
|
||||
(Div(), Div(NoChildren())),
|
||||
(Div(attr1="value"), Div(NoChildren())),
|
||||
(Div(attr1="value1"), Div(AttributeForbidden("attr2"))),
|
||||
(Div(123), Div(123)),
|
||||
(Div(Span(123)), Div(Span(123))),
|
||||
(Div(Span(123)), Div(DoNotCheck())),
|
||||
])
|
||||
def test_i_can_match(actual, expected):
|
||||
assert matches(actual, expected)
|
||||
|
||||
|
||||
class Dummy2:
|
||||
def __init__(self, attr1, attr2):
|
||||
self.attr1 = attr1
|
||||
self.attr2 = attr2
|
||||
@pytest.mark.parametrize('actual, expected, error_message', [
|
||||
(None, Div(), "Actual is None"),
|
||||
(Div(), None, "Actual is not None"),
|
||||
(123, Div(), "The types are different"),
|
||||
(123, 124, "The values are different"),
|
||||
([Div(), Span()], [], "Actual is bigger than expected"),
|
||||
([], [Div(), Span()], "Actual is smaller than expected"),
|
||||
("not a list", [Div(), Span()], "The types are different"),
|
||||
([Div(), Span()], [Div(), 123], "The types are different"),
|
||||
(Div(), Span(), "The elements are different"),
|
||||
([Div(), Span()], [Div(), Div()], "The elements are different"),
|
||||
(Div(), 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=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"),
|
||||
(Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
|
||||
(Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
|
||||
(Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
|
||||
(Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
|
||||
(NotStr("456"), NotStr("123"), "Notstr values are different"),
|
||||
(Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
|
||||
(Div(Span()), Div(NoChildren()), "The condition 'NoChildren()' is not satisfied"),
|
||||
(Div(120), 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(123), "Actual is lesser than expected"),
|
||||
(Div(Span()), Div(Div()), "The elements are different"),
|
||||
(Div(123), Div(Div()), "The types are different"),
|
||||
(Div(123), Div(456), "The values are different"),
|
||||
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
|
||||
(Div(Span(Div())), Div(Span(Span())), "The elements are different"),
|
||||
(Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
|
||||
])
|
||||
def test_i_can_detect_errors(actual, expected, error_message):
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
matches(actual, expected)
|
||||
assert error_message in str(exc_info.value)
|
||||
|
||||
|
||||
class TestMatches:
|
||||
@pytest.mark.parametrize('element, expected_path', [
|
||||
(Div(), "Path : 'div"),
|
||||
(Div(Span()), "Path : 'div.span"),
|
||||
(Div(Span(Div())), "Path : 'div.span.div"),
|
||||
(Div(id="div_id"), "Path : 'div#div_id"),
|
||||
(Div(cls="div_class"), "Path : 'div[class=div_class]"),
|
||||
(Div(name="div_class"), "Path : 'div[name=div_class]"),
|
||||
(Div(attr="value"), "Path : 'div"),
|
||||
(Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].div"),
|
||||
])
|
||||
def test_i_can_properly_show_path(element, expected_path):
|
||||
def _construct_test_element(source, tail):
|
||||
res = MyFT(source.tag, source.attrs)
|
||||
if source.children:
|
||||
res.children = [_construct_test_element(child, tail) for child in source.children]
|
||||
else:
|
||||
res.children = [tail]
|
||||
return res
|
||||
|
||||
@pytest.mark.parametrize('actual, expected', [
|
||||
(None, None),
|
||||
(123, 123),
|
||||
(Div(), Div()),
|
||||
([Div(), Span()], [Div(), Span()]),
|
||||
({"key": Div(attr="value")}, {"key": Div(attr="value")}),
|
||||
({"key": Dummy(attr1="value")}, {"key": TestObject(Dummy, attr1="value")}),
|
||||
(Div(attr1="value"), Div(attr1="value")),
|
||||
(Div(attr1="value", attr2="value"), Div(attr1="value")),
|
||||
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
|
||||
(Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))),
|
||||
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
|
||||
(Div(attr1="value"), Div(attr1=AnyValue())),
|
||||
(None, DoNotCheck()),
|
||||
(123, DoNotCheck()),
|
||||
(Div(), DoNotCheck()),
|
||||
([Div(), Span()], DoNotCheck()),
|
||||
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
|
||||
(Div(), Div(Empty())),
|
||||
(Div(), Div(NoChildren())),
|
||||
(Div(attr1="value"), Div(NoChildren())),
|
||||
(Div(attr1="value1"), Div(AttributeForbidden("attr2"))),
|
||||
(Div(123), Div(123)),
|
||||
(Div(Span(123)), Div(Span(123))),
|
||||
(Div(Span(123)), Div(DoNotCheck())),
|
||||
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value")),
|
||||
(Dummy(123, "value"), TestObject(Dummy, attr2="value")),
|
||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123))),
|
||||
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2="value")),
|
||||
])
|
||||
def test_i_can_match(self, actual, expected):
|
||||
assert matches(actual, expected)
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
actual = _construct_test_element(element, "Actual")
|
||||
expected = _construct_test_element(element, "Expected")
|
||||
matches(actual, expected)
|
||||
|
||||
@pytest.mark.parametrize('actual, expected, error_message', [
|
||||
(None, Div(), "Actual is None"),
|
||||
(Div(), None, "Actual is not None"),
|
||||
(123, Div(), "The types are different"),
|
||||
(123, 124, "The values are different"),
|
||||
([Div(), Span()], [], "Actual is bigger than expected"),
|
||||
([], [Div(), Span()], "Actual is smaller than expected"),
|
||||
("not a list", [Div(), Span()], "The types are different"),
|
||||
([Div(), Span()], [Div(), 123], "The types are different"),
|
||||
(Div(), Span(), "The elements are different"),
|
||||
([Div(), Span()], [Div(), Div()], "The elements are different"),
|
||||
(Div(), 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=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"),
|
||||
(Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
|
||||
(Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
|
||||
(Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
|
||||
(Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
|
||||
(NotStr("456"), NotStr("123"), "Notstr values are different"),
|
||||
(Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
|
||||
(Div(Span()), Div(NoChildren()), "The condition 'NoChildren()' is not satisfied"),
|
||||
(Div(120), 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(123), "Actual is lesser than expected"),
|
||||
(Div(Span()), Div(Div()), "The elements are different"),
|
||||
(Div(123), Div(Div()), "The types are different"),
|
||||
(Div(123), Div(456), "The values are different"),
|
||||
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
|
||||
(Div(Span(Div())), Div(Span(Span())), "The elements are different"),
|
||||
(Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
|
||||
(Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different:"),
|
||||
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr3="value3"), "'attr3' is not found in Actual"),
|
||||
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The values are different for 'attr2'"),
|
||||
(Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different:"),
|
||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"),
|
||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"),
|
||||
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different:"),
|
||||
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
|
||||
|
||||
])
|
||||
def test_i_can_detect_errors(self, actual, expected, error_message):
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
matches(actual, expected)
|
||||
assert error_message in str(exc_info.value)
|
||||
|
||||
@pytest.mark.parametrize('element, expected_path', [
|
||||
(Div(), "Path : 'div"),
|
||||
(Div(Span()), "Path : 'div.span"),
|
||||
(Div(Span(Div())), "Path : 'div.span.div"),
|
||||
(Div(id="div_id"), "Path : 'div#div_id"),
|
||||
(Div(cls="div_class"), "Path : 'div[class=div_class]"),
|
||||
(Div(name="div_class"), "Path : 'div[name=div_class]"),
|
||||
(Div(attr="value"), "Path : 'div"),
|
||||
(Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].div"),
|
||||
])
|
||||
def test_i_can_properly_show_path(self, element, expected_path):
|
||||
def _construct_test_element(source, tail):
|
||||
res = MyFT(source.tag, source.attrs)
|
||||
if source.children:
|
||||
res.children = [_construct_test_element(child, tail) for child in source.children]
|
||||
else:
|
||||
res.children = [tail]
|
||||
return res
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
actual = _construct_test_element(element, "Actual")
|
||||
expected = _construct_test_element(element, "Expected")
|
||||
matches(actual, expected)
|
||||
|
||||
assert expected_path in str(exc_info.value)
|
||||
assert expected_path in str(exc_info.value)
|
||||
|
||||
|
||||
class TestErrorOutput:
|
||||
def test_i_can_output_error_path(self):
|
||||
"""The output follows the representation of the given path"""
|
||||
elt = Div()
|
||||
expected = Div()
|
||||
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "id"="div_id" ...',
|
||||
' (div ...',
|
||||
' (span "class"="span_class" ...',
|
||||
' (p "name"="p_name" ...',
|
||||
' (div )']
|
||||
|
||||
def test_i_can_output_error_attribute(self):
|
||||
elt = Div(attr1="value1", attr2="value2")
|
||||
expected = elt
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
|
||||
|
||||
def test_i_can_output_error_attribute_missing_1(self):
|
||||
elt = Div(attr2="value2")
|
||||
expected = Div(attr1="value1", attr2="value2")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
|
||||
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
|
||||
|
||||
def test_i_can_output_error_attribute_missing_2(self):
|
||||
elt = Div(attr1="value1")
|
||||
expected = Div(attr1="value1", attr2="value2")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
|
||||
' ^^^^^^^^^^^^^^^^^^^^^^^']
|
||||
|
||||
def test_i_can_output_error_attribute_wrong_value(self):
|
||||
elt = Div(attr1="value3", attr2="value2")
|
||||
expected = Div(attr1="value1", attr2="value2")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
|
||||
' ^^^^^^^^^^^^^^^^ ']
|
||||
|
||||
def test_i_can_output_error_constant(self):
|
||||
elt = 123
|
||||
expected = elt
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['123']
|
||||
|
||||
def test_i_can_output_error_constant_wrong_value(self):
|
||||
elt = 123
|
||||
expected = 456
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['123',
|
||||
'^^^']
|
||||
|
||||
def test_i_can_output_error_when_predicate(self):
|
||||
elt = "before value after"
|
||||
expected = Contains("value")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ["before value after"]
|
||||
|
||||
def test_i_can_output_error_when_predicate_wrong_value(self):
|
||||
"""I can display error when the condition predicate is not satisfied."""
|
||||
elt = "before after"
|
||||
expected = Contains("value")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ["before after",
|
||||
"^^^^^^^^^^^^"]
|
||||
|
||||
def test_i_can_output_error_child_element(self):
|
||||
"""I can display error when the element has children"""
|
||||
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||
expected = elt
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1"',
|
||||
' (p "id"="p_id")',
|
||||
' (div "id"="child_1")',
|
||||
' (div "id"="child_2")',
|
||||
')',
|
||||
]
|
||||
|
||||
def test_i_can_output_error_child_element_text(self):
|
||||
"""I can display error when the children is not a FT"""
|
||||
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||
expected = elt
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1"',
|
||||
' "Hello world"',
|
||||
' (div "id"="child_1")',
|
||||
' (div "id"="child_2")',
|
||||
')',
|
||||
]
|
||||
|
||||
def test_i_can_output_error_child_element_indicating_sub_children(self):
|
||||
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
|
||||
expected = elt
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1"',
|
||||
' (p "id"="p_id")',
|
||||
' (div "id"="child_1" ...)',
|
||||
')',
|
||||
]
|
||||
|
||||
def test_i_can_output_error_child_element_wrong_value(self):
|
||||
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
|
||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1"',
|
||||
' (p "id"="p_id")',
|
||||
' (div "id"="child_2")',
|
||||
' ^^^^^^^^^^^^^^',
|
||||
')',
|
||||
]
|
||||
|
||||
def test_i_can_output_error_fewer_elements(self):
|
||||
elt = Div(P(id="p_id"), attr1="value1")
|
||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1"',
|
||||
' (p "id"="p_id")',
|
||||
' ! ** MISSING ** !',
|
||||
')',
|
||||
]
|
||||
|
||||
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_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")',
|
||||
' ^^^^^^^^^^^^^ '
|
||||
]
|
||||
def test_i_can_output_error_path():
|
||||
elt = Div()
|
||||
expected = Div()
|
||||
path = "div#div_id.div.span[class=span_class].p[name=p_name].div"
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "id"="div_id" ...',
|
||||
' (div ...',
|
||||
' (span "class"="span_class" ...',
|
||||
' (p "name"="p_name" ...',
|
||||
' (div )']
|
||||
|
||||
|
||||
class TestErrorComparisonOutput:
|
||||
def test_i_can_output_comparison(self):
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = actual
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
expected_out = ErrorOutput("", expected, expected)
|
||||
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
def test_i_can_output_error_attribute():
|
||||
elt = Div(attr1="value1", attr2="value2")
|
||||
expected = elt
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")']
|
||||
|
||||
|
||||
def test_i_can_output_error_attribute_missing_1():
|
||||
elt = Div(attr2="value2")
|
||||
expected = Div(attr1="value1", attr2="value2")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")',
|
||||
' ^^^^^^^^^^^^^^^^^^^^^^^ ']
|
||||
|
||||
|
||||
def test_i_can_output_error_attribute_missing_2():
|
||||
elt = Div(attr1="value1")
|
||||
expected = Div(attr1="value1", attr2="value2")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")',
|
||||
' ^^^^^^^^^^^^^^^^^^^^^^^']
|
||||
|
||||
|
||||
def test_i_can_output_error_attribute_wrong_value():
|
||||
elt = Div(attr1="value3", attr2="value2")
|
||||
expected = Div(attr1="value1", attr2="value2")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")',
|
||||
' ^^^^^^^^^^^^^^^^ ']
|
||||
|
||||
|
||||
def test_i_can_output_error_constant():
|
||||
elt = 123
|
||||
expected = elt
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['123']
|
||||
|
||||
|
||||
def test_i_can_output_error_constant_wrong_value():
|
||||
elt = 123
|
||||
expected = 456
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['123',
|
||||
'^^^']
|
||||
|
||||
|
||||
def test_i_can_output_error_when_predicate():
|
||||
elt = "before value after"
|
||||
expected = Contains("value")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ["before value after"]
|
||||
|
||||
|
||||
def test_i_can_output_error_when_predicate_wrong_value():
|
||||
"""I can display error when the condition predicate is not satisfied."""
|
||||
elt = "before after"
|
||||
expected = Contains("value")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ["before after",
|
||||
"^^^^^^^^^^^^"]
|
||||
|
||||
|
||||
def test_i_can_output_error_child_element():
|
||||
"""I can display error when the element has children"""
|
||||
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||
expected = elt
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1"',
|
||||
' (p "id"="p_id")',
|
||||
' (div "id"="child_1")',
|
||||
' (div "id"="child_2")',
|
||||
')',
|
||||
]
|
||||
|
||||
|
||||
def test_i_can_output_error_child_element_text():
|
||||
"""I can display error when the children is not a FT"""
|
||||
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
|
||||
expected = elt
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1"',
|
||||
' "Hello world"',
|
||||
' (div "id"="child_1")',
|
||||
' (div "id"="child_2")',
|
||||
')',
|
||||
]
|
||||
|
||||
|
||||
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")
|
||||
expected = elt
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1"',
|
||||
' (p "id"="p_id")',
|
||||
' (div "id"="child_1" ...)',
|
||||
')',
|
||||
]
|
||||
|
||||
|
||||
def test_i_can_output_error_child_element_wrong_value():
|
||||
elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1")
|
||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1"',
|
||||
' (p "id"="p_id")',
|
||||
' (div "id"="child_2")',
|
||||
' ^^^^^^^^^^^^^^',
|
||||
')',
|
||||
]
|
||||
|
||||
|
||||
def test_i_can_output_error_fewer_elements():
|
||||
elt = Div(P(id="p_id"), attr1="value1")
|
||||
expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1")
|
||||
path = ""
|
||||
error_output = ErrorOutput(path, elt, expected)
|
||||
error_output.compute()
|
||||
assert error_output.output == ['(div "attr1"="value1"',
|
||||
' (p "id"="p_id")',
|
||||
' ! ** MISSING ** !',
|
||||
')',
|
||||
]
|
||||
|
||||
|
||||
def test_i_can_output_comparison():
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = actual
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
expected_out = ErrorOutput("", expected, expected)
|
||||
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
(div "attr1"="value1" | (div "attr1"="value1"
|
||||
(p "id"="p_id") | (p "id"="p_id")
|
||||
) | )'''
|
||||
|
||||
|
||||
def test_i_can_output_comparison_with_path():
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = actual
|
||||
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
|
||||
expected_out = ErrorOutput("div#div_id.span[class=cls].div", expected, expected)
|
||||
|
||||
def test_i_can_output_comparison_with_path(self):
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = actual
|
||||
actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected)
|
||||
expected_out = ErrorOutput("div#div_id.span[class=cls].div", expected, expected)
|
||||
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
(div "id"="div_id" ... | (div "id"="div_id" ...
|
||||
(span "class"="cls" ... | (span "class"="cls" ...
|
||||
(div "attr1"="value1" | (div "attr1"="value1"
|
||||
(p "id"="p_id") | (p "id"="p_id")
|
||||
) | )'''
|
||||
|
||||
|
||||
def test_i_can_output_comparison_when_missing_attributes():
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = Div(P(id="p_id"), attr2="value1")
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
expected_out = ErrorOutput("", expected, expected)
|
||||
|
||||
def test_i_can_output_comparison_when_missing_attributes(self):
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = Div(P(id="p_id"), attr2="value1")
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
expected_out = ErrorOutput("", expected, expected)
|
||||
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
(div "attr2"="** MISSING **" | (div "attr2"="value1"
|
||||
^^^^^^^^^^^^^^^^^^^^^^^ |
|
||||
(p "id"="p_id") | (p "id"="p_id")
|
||||
) | )'''
|
||||
|
||||
|
||||
def test_i_can_output_comparison_when_wrong_attributes():
|
||||
actual = Div(P(id="p_id"), attr1="value2")
|
||||
expected = Div(P(id="p_id"), attr1="value1")
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
expected_out = ErrorOutput("", expected, expected)
|
||||
|
||||
def test_i_can_output_comparison_when_wrong_attributes(self):
|
||||
actual = Div(P(id="p_id"), attr1="value2")
|
||||
expected = Div(P(id="p_id"), attr1="value1")
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
expected_out = ErrorOutput("", expected, expected)
|
||||
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
(div "attr1"="value2" | (div "attr1"="value1"
|
||||
^^^^^^^^^^^^^^^^ |
|
||||
(p "id"="p_id") | (p "id"="p_id")
|
||||
) | )'''
|
||||
|
||||
|
||||
def test_i_can_output_comparison_when_fewer_elements():
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
expected_out = ErrorOutput("", expected, expected)
|
||||
|
||||
def test_i_can_output_comparison_when_fewer_elements(self):
|
||||
actual = Div(P(id="p_id"), attr1="value1")
|
||||
expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1")
|
||||
actual_out = ErrorOutput("", actual, expected)
|
||||
expected_out = ErrorOutput("", expected, expected)
|
||||
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
(div "attr1"="value1" | (div "attr1"="value1"
|
||||
(p "id"="p_id") | (span "id"="s_id")
|
||||
^ ^^^^^^^^^^^ |
|
||||
! ** MISSING ** ! | (p "id"="p_id")
|
||||
) | )'''
|
||||
|
||||
|
||||
def test_i_can_see_the_diff_when_matching():
|
||||
actual = Div(attr1="value1")
|
||||
expected = Div(attr1=Contains("value2"))
|
||||
|
||||
def test_i_can_see_the_diff_when_matching(self):
|
||||
actual = Div(attr1="value1")
|
||||
expected = Div(attr1=Contains("value2"))
|
||||
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
matches(actual, expected)
|
||||
|
||||
debug_output = str(exc_info.value)
|
||||
assert "\n" + debug_output == """
|
||||
with pytest.raises(AssertionError) as exc_info:
|
||||
matches(actual, expected)
|
||||
|
||||
debug_output = str(exc_info.value)
|
||||
assert "\n" + debug_output == """
|
||||
Path : 'div'
|
||||
Error : The condition 'Contains(value2)' is not satisfied.
|
||||
(div "attr1"="value1") | (div "attr1"="Contains(value2)")
|
||||
^^^^^^^^^^^^^^^^ |"""
|
||||
|
||||
def test_i_can_see_the_diff_with_test_object_when_wrong_type(self):
|
||||
actual = Div(attr1=123, attr2="value2")
|
||||
expected = TestObject(Dummy, attr1=123, attr2="value2")
|
||||
|
||||
actual_out = ErrorOutput("dummy", actual, expected)
|
||||
expected_out = ErrorOutput("div", expected, expected)
|
||||
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
|
||||
res = comparison_out.render()
|
||||
|
||||
assert "\n" + res == '''
|
||||
(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2")
|
||||
^^^ |'''
|
||||
|
||||
Reference in New Issue
Block a user