Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e3d9b106fb | |||
| d2cf51d7c3 | |||
| 53253278b2 | |||
| 52b4e6a8b6 | |||
| a6ab4b2a68 | |||
| 84c63f0c5a | |||
| bb8752233e | |||
| dd9aefa143 | |||
| b1be747101 | |||
| 97247f824c | |||
| 4199427c71 | |||
| 3de9aff15c | |||
| cdccd0cbaa | |||
| 3667f1df44 | |||
| 66d5169b41 | |||
| e286b60348 | |||
| edcd3ae1a8 | |||
| ca238303b8 | |||
| c38a012c74 | |||
| 09c4217cb6 | |||
| 5ee671c6df | |||
| 9a76bd57ba | |||
| 93f6da66a5 | |||
| 7ff8b3ea14 | |||
| 7238cb085e | |||
| fb57a6a81d | |||
| 7f56b89e66 | |||
| cba4f2aab4 | |||
| c641f3fd63 | |||
| d302261d07 | |||
| a547b2b882 | |||
| 3d46e092aa | |||
| 5cb628099a |
@@ -3,6 +3,7 @@ app.egg-info
|
||||
*.pyc
|
||||
.mypy_cache
|
||||
.coverage
|
||||
.myFastHtmlDb
|
||||
htmlcov
|
||||
.cache
|
||||
.venv
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
# 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
|
||||
|
||||
## 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
|
||||
@@ -25,3 +25,4 @@ clean: clean-build clean-tests
|
||||
clean-all : clean
|
||||
rm -rf src/.sesskey
|
||||
rm -rf src/Users.db
|
||||
rm -rf src/.myFastHtmlDb
|
||||
|
||||
@@ -63,7 +63,7 @@ if __name__ == "__main__":
|
||||
```python
|
||||
from fasthtml import serve
|
||||
|
||||
from myfasthtml.controls.helpers import mk_button
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.myfastapp import create_app
|
||||
|
||||
@@ -82,7 +82,7 @@ app, rt = create_app(protect_routes=False)
|
||||
|
||||
@rt("/")
|
||||
def get_homepage():
|
||||
return mk_button("Click Me!", command=hello_command)
|
||||
return mk.button("Click Me!", command=hello_command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -97,11 +97,17 @@ if __name__ == "__main__":
|
||||
### Bind components
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
|
||||
from myfasthtml.controls.helpers import mk
|
||||
|
||||
|
||||
@dataclass
|
||||
class Data:
|
||||
value: str = "Hello World"
|
||||
checked: bool = False
|
||||
|
||||
|
||||
# Binds an Input with a label
|
||||
mk.mk(Input(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")),
|
||||
mk.mk(Label("Text"), binding=Binding(data, attr="value")),
|
||||
@@ -815,6 +821,24 @@ mk.manage_binding(label_elt, Binding(data))
|
||||
# Input won't trigger updates, but label will still display data
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
session
|
||||
```
|
||||
{'access_token': 'xxx',
|
||||
'refresh_token': 'yyy',
|
||||
'user_info': {
|
||||
'email': 'admin@myauth.com',
|
||||
'username': 'admin',
|
||||
'roles': ['admin'],
|
||||
'user_settings': {},
|
||||
'id': 'uuid',
|
||||
'created_at': '2025-11-10T15:52:59.006213',
|
||||
'updated_at': '2025-11-10T15:52:59.006213'
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
We welcome contributions! To get started:
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
# 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
|
||||
@@ -0,0 +1,439 @@
|
||||
# Mouse Support - Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The mouse support library provides keyboard-like binding capabilities for mouse actions. It supports simple clicks, modified clicks (with Ctrl/Shift/Alt), and sequences of clicks with smart timeout logic.
|
||||
|
||||
## Features
|
||||
|
||||
### Supported Mouse Actions
|
||||
|
||||
**Basic Actions**:
|
||||
- `click` - Left click
|
||||
- `right_click` (or `rclick`) - Right click (contextmenu)
|
||||
|
||||
**Modified Actions**:
|
||||
- `ctrl+click` (or `ctrl+rclick`) - Ctrl+Click (or Cmd+Click on Mac)
|
||||
- `shift+click` (or `shift+rclick`) - Shift+Click
|
||||
- `alt+click` (or `alt+rclick`) - Alt+Click
|
||||
- `ctrl+shift+click` - Multiple modifiers
|
||||
- Any combination of modifiers
|
||||
|
||||
**Sequences**:
|
||||
- `click right_click` (or `click rclick`) - Click then right-click within 500ms
|
||||
- `click click` - Double click sequence
|
||||
- `ctrl+click click` - Ctrl+click then normal click
|
||||
- Any sequence of actions
|
||||
|
||||
**Note**: `rclick` is an alias for `right_click` and can be used interchangeably.
|
||||
|
||||
### Smart Timeout Logic
|
||||
|
||||
Same as keyboard support:
|
||||
- If **any element** has a longer sequence possible, **all matching elements wait**
|
||||
- Timeout is 500ms between actions
|
||||
- Immediate trigger if no longer sequences exist
|
||||
|
||||
### Multiple Element Support
|
||||
|
||||
Multiple elements can listen to the same mouse action and all will trigger simultaneously.
|
||||
|
||||
## Configuration Format
|
||||
|
||||
Uses HTMX configuration objects (same as keyboard support):
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/handle-click",
|
||||
"hx-target": "#result"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/handle-ctrl-click",
|
||||
"hx-swap": "innerHTML"
|
||||
},
|
||||
"rclick": { // Alias for right_click
|
||||
"hx-post": "/context-menu"
|
||||
},
|
||||
"click rclick": { // Can use rclick in sequences too
|
||||
"hx-post": "/sequence-action",
|
||||
"hx-vals": {"type": "sequence"}
|
||||
}
|
||||
};
|
||||
|
||||
add_mouse_support('my-element', JSON.stringify(combinations));
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### add_mouse_support(elementId, combinationsJson)
|
||||
|
||||
Adds mouse support to an element.
|
||||
|
||||
**Parameters**:
|
||||
- `elementId` (string): ID of the HTML element
|
||||
- `combinationsJson` (string): JSON string of combinations with HTMX configs
|
||||
|
||||
**Returns**: void
|
||||
|
||||
**Example**:
|
||||
```javascript
|
||||
add_mouse_support('button1', JSON.stringify({
|
||||
"click": {"hx-post": "/click"},
|
||||
"ctrl+click": {"hx-post": "/ctrl-click"}
|
||||
}));
|
||||
```
|
||||
|
||||
### remove_mouse_support(elementId)
|
||||
|
||||
Removes mouse support from an element.
|
||||
|
||||
**Parameters**:
|
||||
- `elementId` (string): ID of the HTML element
|
||||
|
||||
**Returns**: void
|
||||
|
||||
**Side effects**:
|
||||
- If last element: detaches global event listeners and cleans up all state
|
||||
|
||||
**Example**:
|
||||
```javascript
|
||||
remove_mouse_support('button1');
|
||||
```
|
||||
|
||||
## Automatic Parameters
|
||||
|
||||
The library automatically adds these parameters to every HTMX request:
|
||||
- `combination` - The mouse combination that triggered the action (e.g., "ctrl+click")
|
||||
- `has_focus` - Boolean indicating if the element had focus when clicked
|
||||
- `is_inside` - Boolean indicating if the click was inside the element
|
||||
- For `click`: `true` if clicked inside element, `false` if clicked outside
|
||||
- For `right_click`: always `true` (only triggers when clicking on element)
|
||||
- `has_focus` - Boolean indicating if the element had focus when the action triggered
|
||||
- `clicked_inside` - Boolean indicating if the click was inside the element or outside
|
||||
|
||||
### Parameter Details
|
||||
|
||||
**`has_focus`**:
|
||||
- `true` if the registered element currently has focus
|
||||
- `false` otherwise
|
||||
- Useful for knowing if the element was the active element
|
||||
|
||||
**`clicked_inside`**:
|
||||
- For `click` actions: `true` if clicked on/inside the element, `false` if clicked outside
|
||||
- For `right_click` actions: always `true` (since right-click only triggers on the element)
|
||||
- Useful for "click outside to close" logic
|
||||
|
||||
**Example values sent**:
|
||||
```javascript
|
||||
// User clicks inside a modal
|
||||
{
|
||||
combination: "click",
|
||||
has_focus: true,
|
||||
clicked_inside: true
|
||||
}
|
||||
|
||||
// User clicks outside the modal (modal still gets triggered because click is global)
|
||||
{
|
||||
combination: "click",
|
||||
has_focus: false,
|
||||
clicked_inside: false // Perfect for closing the modal!
|
||||
}
|
||||
|
||||
// User right-clicks on an item
|
||||
{
|
||||
combination: "right_click",
|
||||
has_focus: false,
|
||||
clicked_inside: true // Always true for right_click
|
||||
}
|
||||
```
|
||||
|
||||
## Python Integration
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"click": {
|
||||
"hx-post": "/item/select"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/item/select-multiple",
|
||||
"hx-vals": json.dumps({"mode": "multi"})
|
||||
},
|
||||
"right_click": {
|
||||
"hx-post": "/item/context-menu",
|
||||
"hx-target": "#context-menu",
|
||||
"hx-swap": "innerHTML"
|
||||
}
|
||||
}
|
||||
|
||||
f"add_mouse_support('{element_id}', '{json.dumps(combinations)}')"
|
||||
```
|
||||
|
||||
### Sequences
|
||||
|
||||
```python
|
||||
combinations = {
|
||||
"click": {
|
||||
"hx-post": "/single-click"
|
||||
},
|
||||
"click click": {
|
||||
"hx-post": "/double-click-sequence"
|
||||
},
|
||||
"click right_click": {
|
||||
"hx-post": "/click-then-right-click"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Elements
|
||||
|
||||
```python
|
||||
# Item 1
|
||||
item1_combinations = {
|
||||
"click": {"hx-post": f"/item/1/select"},
|
||||
"ctrl+click": {"hx-post": f"/item/1/toggle"}
|
||||
}
|
||||
f"add_mouse_support('item-1', '{json.dumps(item1_combinations)}')"
|
||||
|
||||
# Item 2
|
||||
item2_combinations = {
|
||||
"click": {"hx-post": f"/item/2/select"},
|
||||
"ctrl+click": {"hx-post": f"/item/2/toggle"}
|
||||
}
|
||||
f"add_mouse_support('item-2', '{json.dumps(item2_combinations)}')"
|
||||
```
|
||||
|
||||
## Behavior Details
|
||||
|
||||
### Click vs Right-Click Behavior
|
||||
|
||||
**IMPORTANT**: The library handles `click` and `right_click` differently:
|
||||
|
||||
**`click` (global detection)**:
|
||||
- Triggers for ALL registered elements, regardless of where you click
|
||||
- Useful for "click outside to close" functionality (modals, dropdowns, popups)
|
||||
- Example: Modal registered with `click` → clicking anywhere on the page triggers the modal's click action
|
||||
|
||||
**`right_click` (element-specific detection)**:
|
||||
- Triggers ONLY when you right-click on (or inside) the registered element
|
||||
- Right-clicking outside the element does nothing and shows browser's context menu
|
||||
- This preserves normal browser behavior while adding custom actions on your elements
|
||||
|
||||
**Example use case**:
|
||||
```javascript
|
||||
// Modal that closes when clicking anywhere
|
||||
add_mouse_support('modal', JSON.stringify({
|
||||
"click": {"hx-post": "/close-modal"} // Triggers even if you click outside modal
|
||||
}));
|
||||
|
||||
// Context menu that only appears on element
|
||||
add_mouse_support('item', JSON.stringify({
|
||||
"right_click": {"hx-post": "/item-menu"} // Only triggers when right-clicking the item
|
||||
}));
|
||||
```
|
||||
|
||||
### Modifier Keys (Cross-Platform)
|
||||
|
||||
- **Windows/Linux**: `ctrl+click` uses Ctrl key
|
||||
- **Mac**: `ctrl+click` uses Cmd (⌘) key OR Ctrl key
|
||||
- This follows standard web conventions for cross-platform compatibility
|
||||
|
||||
### Input Context Protection
|
||||
|
||||
Mouse actions are **disabled** when clicking in input fields:
|
||||
- `<input>` elements
|
||||
- `<textarea>` elements
|
||||
- Any `contenteditable` element
|
||||
|
||||
This ensures normal text selection and interaction works in forms.
|
||||
|
||||
### Right-Click Menu
|
||||
|
||||
The contextmenu (right-click menu) is prevented with `preventDefault()` when:
|
||||
- A `right_click` action is configured for the element
|
||||
- The element is NOT an input/textarea/contenteditable
|
||||
|
||||
### Event Bubbling
|
||||
|
||||
The library checks if the click target OR any parent element is registered:
|
||||
```html
|
||||
<div id="container">
|
||||
<button>Click me</button>
|
||||
</div>
|
||||
```
|
||||
If `container` is registered, clicking the button will trigger the container's actions.
|
||||
|
||||
## Examples
|
||||
|
||||
### Using Aliases
|
||||
|
||||
You can use `rclick` instead of `right_click` anywhere:
|
||||
|
||||
```javascript
|
||||
// These are equivalent
|
||||
const config1 = {
|
||||
"right_click": {"hx-post": "/menu"}
|
||||
};
|
||||
|
||||
const config2 = {
|
||||
"rclick": {"hx-post": "/menu"} // Shorter alias
|
||||
};
|
||||
|
||||
// Works in sequences too
|
||||
const config3 = {
|
||||
"click rclick": {"hx-post": "/sequence"}
|
||||
};
|
||||
|
||||
// Works with modifiers
|
||||
const config4 = {
|
||||
"ctrl+rclick": {"hx-post": "/ctrl-right-click"}
|
||||
};
|
||||
```
|
||||
|
||||
### Context Menu
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"right_click": {
|
||||
"hx-post": "/show-context-menu",
|
||||
"hx-target": "#menu",
|
||||
"hx-swap": "innerHTML"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Close Modal/Popup on Click Outside
|
||||
|
||||
Since `click` is detected globally (anywhere on the page), it's perfect for "click outside to close":
|
||||
|
||||
```javascript
|
||||
// Modal element
|
||||
const modalCombinations = {
|
||||
"click": {
|
||||
"hx-post": "/close-modal",
|
||||
"hx-target": "#modal",
|
||||
"hx-swap": "outerHTML"
|
||||
}
|
||||
};
|
||||
add_mouse_support('modal', JSON.stringify(modalCombinations));
|
||||
|
||||
// Now clicking ANYWHERE on the page will trigger the handler
|
||||
// Your backend receives:
|
||||
// - clicked_inside: true (if clicked on modal) → maybe keep it open
|
||||
// - clicked_inside: false (if clicked outside) → close it!
|
||||
```
|
||||
|
||||
**Backend example (Python/Flask)**:
|
||||
```python
|
||||
@app.route('/close-modal', methods=['POST'])
|
||||
def close_modal():
|
||||
clicked_inside = request.form.get('clicked_inside') == 'true'
|
||||
|
||||
if clicked_inside:
|
||||
# User clicked inside the modal - keep it open
|
||||
return render_template('modal_content.html')
|
||||
else:
|
||||
# User clicked outside - close the modal
|
||||
return '' # Empty response removes the modal
|
||||
```
|
||||
|
||||
**Note**: The click handler on the modal element will trigger for all clicks on the page, not just clicks on the modal itself. Use the `clicked_inside` parameter to determine the appropriate action.
|
||||
|
||||
### Multi-Select List
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/select-item",
|
||||
"hx-vals": {"mode": "single"}
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/select-item",
|
||||
"hx-vals": {"mode": "toggle"}
|
||||
},
|
||||
"shift+click": {
|
||||
"hx-post": "/select-range",
|
||||
"hx-vals": {"mode": "range"}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Interactive Canvas/Drawing
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/draw-point"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/draw-special"
|
||||
},
|
||||
"click click": {
|
||||
"hx-post": "/confirm-action"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Drag-and-Drop Alternative
|
||||
|
||||
```javascript
|
||||
const combinations = {
|
||||
"click": {
|
||||
"hx-post": "/select-source"
|
||||
},
|
||||
"click click": {
|
||||
"hx-post": "/set-destination"
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Clicks not detected
|
||||
|
||||
- Verify the element exists and has the correct ID
|
||||
- Check browser console for errors
|
||||
- Ensure HTMX is loaded before mouse_support.js
|
||||
|
||||
### Right-click menu still appears
|
||||
|
||||
- Check if element is in input context (input/textarea)
|
||||
- Verify the combination is configured correctly
|
||||
- Check browser console for configuration errors
|
||||
|
||||
### Sequences not working
|
||||
|
||||
- Ensure clicks happen within 500ms timeout
|
||||
- Check if longer sequences exist (causes waiting)
|
||||
- Verify the combination string format (space-separated)
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Global listeners** on `document` for `click` and `contextmenu` events
|
||||
- **Tree-based matching** using prefix trees (same as keyboard support)
|
||||
- **Single timeout** for all elements (sequence-based, not element-based)
|
||||
- **Independent from keyboard support** (separate registry and timeouts)
|
||||
|
||||
### Performance
|
||||
|
||||
- Single event listener regardless of number of elements
|
||||
- O(n) matching where n is sequence length
|
||||
- Efficient memory usage with automatic cleanup
|
||||
|
||||
### Browser Compatibility
|
||||
|
||||
- Modern browsers (ES6+ required)
|
||||
- Chrome, Firefox, Safari, Edge
|
||||
- Requires HTMX library
|
||||
|
||||
## Notes
|
||||
|
||||
- Timeout value is the same as keyboard support (500ms) but in separate variable
|
||||
- Can be used independently or alongside keyboard support
|
||||
- Does not interfere with normal mouse behavior in inputs
|
||||
- Element must exist in DOM when `add_mouse_support()` is called
|
||||
- **Alias**: `rclick` can be used interchangeably with `right_click` for shorter syntax
|
||||
@@ -35,9 +35,11 @@ dependencies = [
|
||||
"email-validator",
|
||||
"httptools",
|
||||
"myauth",
|
||||
"mydbengine",
|
||||
"myutils",
|
||||
"python-fasthtml",
|
||||
"PyYAML",
|
||||
"typer",
|
||||
"uvloop",
|
||||
"watchfiles",
|
||||
"websockets",
|
||||
|
||||
+10
-1
@@ -16,6 +16,7 @@ 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
|
||||
@@ -35,11 +36,15 @@ keyring==25.6.0
|
||||
markdown-it-py==4.0.0
|
||||
mdurl==0.1.2
|
||||
more-itertools==10.8.0
|
||||
myauth==0.2.0
|
||||
myauth==0.2.1
|
||||
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
|
||||
@@ -56,6 +61,7 @@ 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
|
||||
@@ -64,13 +70,16 @@ rfc3986==2.0.0
|
||||
rich==14.2.0
|
||||
rsa==4.9.1
|
||||
SecretStorage==3.4.0
|
||||
shellingham==1.5.4
|
||||
six==1.17.0
|
||||
sniffio==1.3.1
|
||||
soupsieve==2.8
|
||||
starlette==0.48.0
|
||||
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
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import logging.config
|
||||
|
||||
import yaml
|
||||
from fasthtml import serve
|
||||
|
||||
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.helpers import Ids, mk
|
||||
from myfasthtml.core.instances import UniqueInstance
|
||||
from myfasthtml.icons.carbon import volume_object_storage
|
||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
||||
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)
|
||||
|
||||
app, rt = create_app(protect_routes=True,
|
||||
mount_auth_app=True,
|
||||
pico=False,
|
||||
vis=True,
|
||||
title="MyFastHtml",
|
||||
live=True,
|
||||
base_url="http://localhost:5003")
|
||||
|
||||
|
||||
@rt("/")
|
||||
def index(session):
|
||||
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
|
||||
layout = Layout(session_instance, "Testing Layout")
|
||||
layout.set_footer("Goodbye World")
|
||||
|
||||
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"),
|
||||
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=None,
|
||||
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")))
|
||||
|
||||
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.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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# debug_routes(app)
|
||||
serve(port=5003)
|
||||
@@ -0,0 +1,54 @@
|
||||
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,15 +1,468 @@
|
||||
.mf-icon-20 {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
: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-16 {
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
margin-top: auto;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/*
|
||||
* MF Layout Component - CSS Grid Layout
|
||||
* Provides fixed header/footer, collapsible drawers, and scrollable main content
|
||||
* Compatible with DaisyUI 5
|
||||
*/
|
||||
|
||||
/* Main layout container using CSS Grid */
|
||||
.mf-layout {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header header header"
|
||||
"left-drawer main right-drawer"
|
||||
"footer footer footer";
|
||||
grid-template-rows: 32px 1fr 32px;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header - fixed at top */
|
||||
.mf-layout-header {
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between; /* put one item on each side */
|
||||
gap: 1rem;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-base-300);
|
||||
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Footer - fixed at bottom */
|
||||
.mf-layout-footer {
|
||||
grid-area: footer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
background-color: var(--color-neutral);
|
||||
color: var(--color-neutral-content);
|
||||
border-top: 1px solid color-mix(in oklab, var(--color-base-content) 10%, #0000);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
/* Main content area - scrollable */
|
||||
.mf-layout-main {
|
||||
grid-area: main;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem;
|
||||
background-color: var(--color-base-100);
|
||||
}
|
||||
|
||||
/* Drawer base styles */
|
||||
.mf-layout-drawer {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--color-base-100);
|
||||
transition: width 0.3s ease-in-out, margin 0.3s ease-in-out;
|
||||
width: 250px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Left drawer */
|
||||
.mf-layout-left-drawer {
|
||||
grid-area: left-drawer;
|
||||
border-right: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* Collapsed drawer states */
|
||||
.mf-layout-drawer.collapsed {
|
||||
width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Toggle buttons positioning */
|
||||
.mf-layout-toggle-left {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.mf-layout-toggle-right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Smooth scrollbar styling for webkit browsers */
|
||||
.mf-layout-main::-webkit-scrollbar,
|
||||
.mf-layout-drawer::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-track,
|
||||
.mf-layout-drawer::-webkit-scrollbar-track {
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-thumb,
|
||||
.mf-layout-drawer::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in oklab, var(--color-base-content) 20%, #0000);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mf-layout-main::-webkit-scrollbar-thumb:hover,
|
||||
.mf-layout-drawer::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in oklab, var(--color-base-content) 30%, #0000);
|
||||
}
|
||||
|
||||
/* Responsive adjustments for smaller screens */
|
||||
@media (max-width: 768px) {
|
||||
.mf-layout-drawer {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.mf-layout-header,
|
||||
.mf-layout-footer {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.mf-layout-main {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Handle layouts with no drawers */
|
||||
.mf-layout[data-left-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"main right-drawer"
|
||||
"footer footer";
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
|
||||
.mf-layout[data-right-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"left-drawer main"
|
||||
"footer footer";
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
.mf-layout[data-left-drawer="false"][data-right-drawer="false"] {
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"main"
|
||||
"footer";
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Layout Drawer Resizer Styles
|
||||
*
|
||||
* Styles for the resizable drawer borders with visual feedback
|
||||
*/
|
||||
|
||||
/* Ensure drawer has relative positioning and no overflow */
|
||||
.mf-layout-drawer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Content wrapper handles scrolling */
|
||||
.mf-layout-drawer-content {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Base resizer styles */
|
||||
.mf-layout-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* Resizer on the right side (for left drawer) */
|
||||
.mf-layout-resizer-right {
|
||||
right: 0;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Resizer on the left side (for right drawer) */
|
||||
.mf-layout-resizer-left {
|
||||
left: 0;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Hover state */
|
||||
.mf-layout-resizer:hover {
|
||||
background-color: rgba(59, 130, 246, 0.3); /* Blue-500 with opacity */
|
||||
}
|
||||
|
||||
/* Active state during resize */
|
||||
.mf-layout-drawer-resizing .mf-layout-resizer {
|
||||
background-color: rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
/* Disable transitions during resize for smooth dragging */
|
||||
.mf-layout-drawer-resizing {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Prevent text selection during resize */
|
||||
.mf-layout-resizing {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Cursor override for entire body during resize */
|
||||
.mf-layout-resizing * {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
/* Visual indicator for resizer on hover - subtle border */
|
||||
.mf-layout-resizer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 2px;
|
||||
height: 40px;
|
||||
background-color: rgba(156, 163, 175, 0.4); /* Gray-400 with opacity */
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-layout-resizer-right::before {
|
||||
right: 1px;
|
||||
}
|
||||
|
||||
.mf-layout-resizer-left::before {
|
||||
left: 1px;
|
||||
}
|
||||
|
||||
.mf-layout-resizer:hover::before,
|
||||
.mf-layout-drawer-resizing .mf-layout-resizer::before {
|
||||
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 ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
/* Tabs Manager Container */
|
||||
.mf-tabs-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--color-base-200);
|
||||
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
/* Tabs Header using DaisyUI tabs component */
|
||||
.mf-tabs-header {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
flex-shrink: 1;
|
||||
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 */
|
||||
.mf-tab-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.5rem 0 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-tab-button:hover {
|
||||
color: var(--color-base-content); /* Change text color on hover */
|
||||
}
|
||||
|
||||
.mf-tab-button.mf-tab-active {
|
||||
--depth: 1;
|
||||
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);
|
||||
}
|
||||
|
||||
/* Tab Label */
|
||||
.mf-tab-label {
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
/* Tab Close Button */
|
||||
.mf-tab-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
@apply text-base-content/50;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mf-tab-close-btn:hover {
|
||||
@apply bg-base-300 text-error;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.mf-empty-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+34
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:5001" # Your FastAPI backend URL
|
||||
API_BASE_URL = "http://localhost:5003" # Your FastAPI backend URL
|
||||
JWT_SECRET = "jwt-secret-to-change" # Must match your FastAPI secret
|
||||
```
|
||||
|
||||
|
||||
@@ -16,9 +16,10 @@ 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"):
|
||||
def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db", base_url=None):
|
||||
"""
|
||||
Setup all authentication and protected routes.
|
||||
|
||||
@@ -27,6 +28,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db"):
|
||||
rt: Route decorator from FastHTML
|
||||
mount_auth_app: Whether to mount the auth FastApi API routes
|
||||
sqlite_db_path: by default, create a new SQLite database at this path
|
||||
base_url: Base URL for the application (default to localhost:5001 if not provided)
|
||||
"""
|
||||
|
||||
# ============================================================================
|
||||
@@ -61,7 +63,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db"):
|
||||
RedirectResponse on success, or LoginPage with error on failure
|
||||
"""
|
||||
# Attempt login
|
||||
auth_data = login_user(email, password)
|
||||
auth_data = login_user(email, password, base_url=base_url)
|
||||
|
||||
if auth_data:
|
||||
# Login successful - store tokens in session
|
||||
@@ -69,7 +71,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db"):
|
||||
session['refresh_token'] = auth_data['refresh_token']
|
||||
|
||||
# Get user info and store in session
|
||||
user_info = get_user_info(auth_data['access_token'])
|
||||
user_info = get_user_info(auth_data['access_token'], base_url=base_url)
|
||||
if user_info:
|
||||
session['user_info'] = user_info
|
||||
|
||||
@@ -116,7 +118,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db"):
|
||||
return RegisterPage(error_message="Password must be at least 8 characters long.")
|
||||
|
||||
# Attempt registration
|
||||
result = register_user(email, username, password)
|
||||
result = register_user(email, username, password, base_url=base_url)
|
||||
|
||||
if result:
|
||||
# Registration successful - show success message and auto-login
|
||||
@@ -180,6 +182,9 @@ def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db"):
|
||||
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:5001" # Base URL for FastAPI backend
|
||||
API_BASE_URL = "http://localhost:5003" # 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
|
||||
@@ -63,14 +63,12 @@ def auth_before(request, session):
|
||||
Args:
|
||||
request: Starlette request object
|
||||
session: FastHTML session object
|
||||
|
||||
Returns:
|
||||
RedirectResponse to login page if authentication fails, None otherwise
|
||||
"""
|
||||
# Get tokens from session
|
||||
access_token = session.get('access_token')
|
||||
refresh_token = session.get('refresh_token')
|
||||
print(f"path={request.scope['path']}, {session=}, {access_token=}, {refresh_token=}")
|
||||
# If no access token, redirect to login
|
||||
if not access_token:
|
||||
return RedirectResponse('/login', status_code=303)
|
||||
@@ -163,13 +161,14 @@ def check_token_expiry(token: str) -> Optional[float]:
|
||||
return None
|
||||
|
||||
|
||||
def login_user(email: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
def login_user(email: str, password: str, base_url: str = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Authenticate user with email and password.
|
||||
|
||||
Args:
|
||||
email: User email address
|
||||
password: User password
|
||||
base_url:
|
||||
|
||||
Returns:
|
||||
Dictionary containing access_token, refresh_token, and user_info if successful,
|
||||
@@ -177,7 +176,7 @@ def login_user(email: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
try:
|
||||
response = http_client.post(
|
||||
f"{API_BASE_URL}/auth/login",
|
||||
f"{base_url or API_BASE_URL}/auth/login",
|
||||
data={"username": email, "password": password},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=10.0
|
||||
@@ -196,7 +195,7 @@ def login_user(email: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def register_user(email: str, username: str, password: str) -> Optional[Dict[str, Any]]:
|
||||
def register_user(email: str, username: str, password: str, base_url: str = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Register a new user.
|
||||
|
||||
@@ -204,14 +203,14 @@ def register_user(email: str, username: str, password: str) -> Optional[Dict[str
|
||||
email: User email address
|
||||
username: User name
|
||||
password: User password
|
||||
|
||||
base_url:
|
||||
Returns:
|
||||
Dictionary containing success message if registration succeeds,
|
||||
None if registration fails
|
||||
"""
|
||||
try:
|
||||
response = http_client.post(
|
||||
f"{API_BASE_URL}/auth/register",
|
||||
f"{base_url or API_BASE_URL}/auth/register",
|
||||
json={"email": email, "username": username, "password": password},
|
||||
timeout=10.0
|
||||
)
|
||||
@@ -224,20 +223,20 @@ def register_user(email: str, username: str, password: str) -> Optional[Dict[str
|
||||
return None
|
||||
|
||||
|
||||
def refresh_access_token(refresh_token: str) -> Optional[Dict[str, Any]]:
|
||||
def refresh_access_token(refresh_token: str, base_url: str = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Refresh the access token using a refresh token.
|
||||
|
||||
Args:
|
||||
refresh_token: Valid refresh token
|
||||
|
||||
base_url:
|
||||
Returns:
|
||||
Dictionary containing new access_token and refresh_token if successful,
|
||||
None if refresh fails
|
||||
"""
|
||||
try:
|
||||
response = http_client.post(
|
||||
f"{API_BASE_URL}/auth/refresh",
|
||||
f"{base_url or API_BASE_URL}/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
timeout=10.0
|
||||
)
|
||||
@@ -254,20 +253,20 @@ def refresh_access_token(refresh_token: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def get_user_info(access_token: str) -> Optional[Dict[str, Any]]:
|
||||
def get_user_info(access_token: str, base_url: str = None) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get current user information using access token.
|
||||
|
||||
Args:
|
||||
access_token: Valid access token
|
||||
|
||||
base_url:
|
||||
Returns:
|
||||
Dictionary containing user information if successful,
|
||||
None if request fails
|
||||
"""
|
||||
try:
|
||||
response = http_client.get(
|
||||
f"{API_BASE_URL}/auth/me",
|
||||
f"{base_url or API_BASE_URL}/auth/me",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=10.0
|
||||
)
|
||||
@@ -280,19 +279,36 @@ def get_user_info(access_token: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def logout_user(refresh_token: str) -> bool:
|
||||
def save_user_info(access_token: str, user_profile: dict, base_url: str = None):
|
||||
try:
|
||||
response = http_client.patch(
|
||||
f"{base_url or API_BASE_URL}/auth/me",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=10.0,
|
||||
json=user_profile
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
|
||||
return None
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
def logout_user(refresh_token: str, base_url: str = None) -> bool:
|
||||
"""
|
||||
Logout user by revoking the refresh token.
|
||||
|
||||
Args:
|
||||
refresh_token: Refresh token to revoke
|
||||
|
||||
base_url:
|
||||
Returns:
|
||||
True if logout successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
response = http_client.post(
|
||||
f"{API_BASE_URL}/auth/logout",
|
||||
f"{base_url or API_BASE_URL}/auth/logout",
|
||||
json={"refresh_token": refresh_token},
|
||||
timeout=10.0
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class BaseCommands:
|
||||
def __init__(self, owner):
|
||||
self._owner = owner
|
||||
self._id = owner.get_id()
|
||||
self._prefix = owner.get_prefix()
|
||||
@@ -0,0 +1,62 @@
|
||||
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()
|
||||
@@ -0,0 +1,39 @@
|
||||
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()
|
||||
@@ -0,0 +1,94 @@
|
||||
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()
|
||||
self._toggle_command = self.commands.toggle()
|
||||
|
||||
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, "-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';
|
||||
# }
|
||||
# }
|
||||
# });
|
||||
@@ -0,0 +1,98 @@
|
||||
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()
|
||||
@@ -0,0 +1,34 @@
|
||||
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)
|
||||
|
||||
def render(self):
|
||||
s_name = InstancesManager.get_session_user_name
|
||||
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"
|
||||
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||
# vis_network.add_to_options(physics={"wind": {"x": 0, "y": 1}})
|
||||
return vis_network
|
||||
|
||||
def _get_instances(self):
|
||||
return list(InstancesManager.instances.values())
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -0,0 +1,23 @@
|
||||
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, _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_keyboard_support('{self._parent.get_id()}', '{json.dumps(str_combinations)}')")
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Layout component for FastHTML applications.
|
||||
|
||||
This component provides a responsive layout with fixed header/footer,
|
||||
optional collapsible left/right drawers, and a scrollable main content area.
|
||||
"""
|
||||
import logging
|
||||
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.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.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
|
||||
|
||||
logger = logging.getLogger("LayoutControl")
|
||||
|
||||
|
||||
class LayoutState(DbObject):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
with self.initializing():
|
||||
self.left_drawer_open: bool = True
|
||||
self.right_drawer_open: bool = True
|
||||
self.left_drawer_width: int = 250
|
||||
self.right_drawer_width: int = 250
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
|
||||
|
||||
def update_drawer_width(self, side: Literal["left", "right"]):
|
||||
"""
|
||||
Create a command to update drawer width.
|
||||
|
||||
Args:
|
||||
side: Which drawer to update ("left" or "right")
|
||||
|
||||
Returns:
|
||||
Command: Command object for updating drawer width
|
||||
"""
|
||||
return Command(
|
||||
f"UpdateDrawerWidth_{side}",
|
||||
f"Update {side} drawer width",
|
||||
self._owner.update_drawer_width,
|
||||
side
|
||||
)
|
||||
|
||||
|
||||
class Layout(SingleInstance):
|
||||
"""
|
||||
A responsive layout component with header, footer, main content area,
|
||||
and optional collapsible side drawers.
|
||||
|
||||
Attributes:
|
||||
app_name (str): Name of the application
|
||||
left_drawer (bool): Whether to include a left drawer
|
||||
right_drawer (bool): Whether to include a right drawer
|
||||
"""
|
||||
|
||||
class Content:
|
||||
def __init__(self, owner):
|
||||
self._owner = owner
|
||||
self._content = {}
|
||||
self._groups = []
|
||||
self._ids = set()
|
||||
|
||||
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 get_content(self):
|
||||
return self._content
|
||||
|
||||
def get_groups(self):
|
||||
return self._groups
|
||||
|
||||
def __init__(self, parent, app_name, _id=None):
|
||||
"""
|
||||
Initialize the Layout component.
|
||||
|
||||
Args:
|
||||
app_name (str): Name of the application
|
||||
left_drawer (bool): Enable left drawer. Default is True.
|
||||
right_drawer (bool): Enable right drawer. Default is True.
|
||||
"""
|
||||
super().__init__(parent, _id=_id)
|
||||
self.app_name = app_name
|
||||
|
||||
# Content storage
|
||||
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)
|
||||
|
||||
def set_footer(self, content):
|
||||
"""
|
||||
Set the footer content.
|
||||
|
||||
Args:
|
||||
content: FastHTML component(s) or content for the footer
|
||||
"""
|
||||
self._footer_content = content
|
||||
|
||||
def set_main(self, content):
|
||||
"""
|
||||
Set the main content area.
|
||||
|
||||
Args:
|
||||
content: FastHTML component(s) or content for the main area
|
||||
"""
|
||||
self._main_content = content
|
||||
|
||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
|
||||
if side == "left":
|
||||
self._state.left_drawer_open = not self._state.left_drawer_open
|
||||
return self._mk_left_drawer_icon(), self._mk_left_drawer()
|
||||
elif side == "right":
|
||||
self._state.right_drawer_open = not self._state.right_drawer_open
|
||||
return self._mk_right_drawer_icon(), self._mk_right_drawer()
|
||||
else:
|
||||
raise ValueError("Invalid drawer side")
|
||||
|
||||
def update_drawer_width(self, side: Literal["left", "right"], width: int):
|
||||
"""
|
||||
Update the width of a drawer.
|
||||
|
||||
Args:
|
||||
side: Which drawer to update ("left" or "right")
|
||||
width: New width in pixels
|
||||
|
||||
Returns:
|
||||
Div: Updated drawer component
|
||||
"""
|
||||
# Constrain width between min and max values
|
||||
width = max(150, min(600, width))
|
||||
|
||||
logger.debug(f"Update drawer width: {side=}, {width=}")
|
||||
|
||||
if side == "left":
|
||||
self._state.left_drawer_width = width
|
||||
return self._mk_left_drawer()
|
||||
elif side == "right":
|
||||
self._state.right_drawer_width = width
|
||||
return self._mk_right_drawer()
|
||||
else:
|
||||
raise ValueError("Invalid drawer side")
|
||||
|
||||
def _mk_header(self):
|
||||
"""
|
||||
Render the header component.
|
||||
|
||||
Returns:
|
||||
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"
|
||||
),
|
||||
cls="mf-layout-header"
|
||||
)
|
||||
|
||||
def _mk_footer(self):
|
||||
"""
|
||||
Render the footer component.
|
||||
|
||||
Returns:
|
||||
Footer: FastHTML Footer component
|
||||
"""
|
||||
footer_content = self._footer_content if self._footer_content else ""
|
||||
return Footer(
|
||||
footer_content,
|
||||
cls="mf-layout-footer footer sm:footer-horizontal"
|
||||
)
|
||||
|
||||
def _mk_main(self):
|
||||
"""
|
||||
Render the main content area.
|
||||
|
||||
Returns:
|
||||
Main: FastHTML Main component
|
||||
"""
|
||||
main_content = self._main_content if self._main_content else ""
|
||||
return Main(
|
||||
main_content,
|
||||
cls="mf-layout-main"
|
||||
)
|
||||
|
||||
def _mk_left_drawer(self):
|
||||
"""
|
||||
Render the left drawer if enabled.
|
||||
|
||||
Returns:
|
||||
Div: FastHTML Div component for left drawer
|
||||
"""
|
||||
resizer = Div(
|
||||
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())
|
||||
],
|
||||
cls="mf-layout-drawer-content"
|
||||
)
|
||||
|
||||
return Div(
|
||||
content_wrapper,
|
||||
resizer,
|
||||
id=f"{self._id}_ld",
|
||||
cls=f"mf-layout-drawer mf-layout-left-drawer {'collapsed' if not self._state.left_drawer_open else ''}",
|
||||
style=f"width: {self._state.left_drawer_width if self._state.left_drawer_open else 0}px;"
|
||||
)
|
||||
|
||||
def _mk_right_drawer(self):
|
||||
"""
|
||||
Render the right drawer if enabled.
|
||||
|
||||
Returns:
|
||||
Div: FastHTML Div component for right drawer
|
||||
"""
|
||||
resizer = Div(
|
||||
cls="mf-layout-resizer mf-layout-resizer-left",
|
||||
data_command_id=self.commands.update_drawer_width("right").id,
|
||||
data_side="right"
|
||||
)
|
||||
|
||||
# Wrap content in scrollable container
|
||||
content_wrapper = Div(
|
||||
*self.right_drawer.get_content(),
|
||||
cls="mf-layout-drawer-content"
|
||||
)
|
||||
|
||||
return Div(
|
||||
resizer,
|
||||
content_wrapper,
|
||||
cls=f"mf-layout-drawer mf-layout-right-drawer {'collapsed' if not self._state.right_drawer_open else ''}",
|
||||
id=f"{self._id}_rd",
|
||||
style=f"width: {self._state.right_drawer_width if self._state.right_drawer_open else 0}px;"
|
||||
)
|
||||
|
||||
def _mk_left_drawer_icon(self):
|
||||
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
|
||||
id=f"{self._id}_ldi",
|
||||
command=self.commands.toggle_drawer("left"))
|
||||
|
||||
def _mk_right_drawer_icon(self):
|
||||
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
|
||||
id=f"{self._id}_rdi",
|
||||
command=self.commands.toggle_drawer("right"))
|
||||
|
||||
def render(self):
|
||||
"""
|
||||
Render the complete layout.
|
||||
|
||||
Returns:
|
||||
Div: Complete layout as FastHTML Div component
|
||||
"""
|
||||
|
||||
# Wrap everything in a container div
|
||||
return Div(
|
||||
self._mk_header(),
|
||||
self._mk_left_drawer(),
|
||||
self._mk_main(),
|
||||
self._mk_right_drawer(),
|
||||
self._mk_footer(),
|
||||
Script(f"initLayoutResizer('{self._id}');"),
|
||||
id=self._id,
|
||||
cls="mf-layout",
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
"""
|
||||
FastHTML magic method for rendering.
|
||||
|
||||
Returns:
|
||||
Div: The rendered layout
|
||||
"""
|
||||
return self.render()
|
||||
@@ -0,0 +1,23 @@
|
||||
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()
|
||||
@@ -0,0 +1,90 @@
|
||||
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()
|
||||
@@ -0,0 +1,404 @@
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import Div, Span
|
||||
from fasthtml.xtend import Script
|
||||
|
||||
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.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}
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Boundaries:
|
||||
"""Store component boundaries"""
|
||||
width: int = 1020
|
||||
height: int = 782
|
||||
|
||||
|
||||
class TabsManagerState(DbObject):
|
||||
def __init__(self, owner):
|
||||
super().__init__(owner)
|
||||
with self.initializing():
|
||||
# persisted in DB
|
||||
self.tabs: dict[str, Any] = {}
|
||||
self.tabs_order: list[str] = []
|
||||
self.active_tab: str | None = None
|
||||
|
||||
# must not be persisted in DB
|
||||
self._tabs_content: dict[str, Any] = {}
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def show_tab(self, tab_id):
|
||||
return Command(f"{self._prefix}ShowTab",
|
||||
"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"))
|
||||
|
||||
|
||||
class TabsManager(MultipleInstance):
|
||||
_tab_count = 0
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _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 add_tab(self, label: str, component: Any, activate: bool = True) -> str:
|
||||
"""
|
||||
Add a new tab or update an existing one with the same component type, ID and label.
|
||||
|
||||
Args:
|
||||
label: Display label for the tab
|
||||
component: Component instance to display in the tab
|
||||
activate: Whether to activate the new/updated tab immediately (default: True)
|
||||
|
||||
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_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)
|
||||
|
||||
if existing_tab_id:
|
||||
# Update existing tab (only the component instance in memory)
|
||||
tab_id = existing_tab_id
|
||||
state._tabs_content[tab_id] = component
|
||||
else:
|
||||
# Create new tab
|
||||
tab_id = str(uuid.uuid4())
|
||||
|
||||
# Add tab metadata to state
|
||||
state.tabs[tab_id] = {
|
||||
'id': tab_id,
|
||||
'label': label,
|
||||
'component_type': component_type,
|
||||
'component_id': component_id
|
||||
}
|
||||
|
||||
# Add tab to order
|
||||
state.tabs_order.append(tab_id)
|
||||
|
||||
# Store component in memory
|
||||
state._tabs_content[tab_id] = component
|
||||
|
||||
# Activate tab if requested
|
||||
if activate:
|
||||
state.active_tab = tab_id
|
||||
|
||||
# finally, update the state
|
||||
self._state.update(state)
|
||||
self._search.set_items(self._get_tab_list())
|
||||
|
||||
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):
|
||||
"""
|
||||
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(
|
||||
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 ''}",
|
||||
data_tab_id=tab_id,
|
||||
data_manager_id=self._id
|
||||
)
|
||||
|
||||
def _mk_tab_content_wrapper(self):
|
||||
"""
|
||||
Create the active tab content area.
|
||||
|
||||
Returns:
|
||||
Div element containing the active tab content or empty container
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
return Div(
|
||||
tab_content,
|
||||
cls="mf-tab-content-wrapper",
|
||||
id=f"{self._id}-content-wrapper",
|
||||
)
|
||||
|
||||
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
|
||||
"""
|
||||
return Div(
|
||||
self._mk_tabs_controller(),
|
||||
self._mk_tabs_header_wrapper(),
|
||||
self._mk_tab_content_wrapper(),
|
||||
cls="mf-tabs-manager",
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -0,0 +1,83 @@
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.AuthProxy import AuthProxy
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, RootInstance
|
||||
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
|
||||
|
||||
|
||||
class UserProfileState:
|
||||
def __init__(self, owner):
|
||||
self._owner = owner
|
||||
self._session = owner.get_session()
|
||||
|
||||
self.theme = "light"
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
user_info = retrieve_user_info(self._session)
|
||||
user_settings = user_info.get("user_settings", {})
|
||||
for k, v in user_settings.items():
|
||||
if hasattr(self, k):
|
||||
setattr(self, k, v)
|
||||
|
||||
def save(self):
|
||||
user_settings = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
||||
auth_proxy = AuthProxy(RootInstance)
|
||||
auth_proxy.save_user_info(self._session["access_token"], {"user_settings": user_settings})
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def update_dark_mode(self):
|
||||
return Command("UpdateDarkMode", "Set the dark mode", self._owner.update_dark_mode).htmx(target=None)
|
||||
|
||||
|
||||
class UserProfile(SingleInstance):
|
||||
def __init__(self, parent=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
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)
|
||||
return Div(
|
||||
Div(user_info['username'],
|
||||
tabindex="0",
|
||||
role="button",
|
||||
cls="btn btn-xs"),
|
||||
Div(
|
||||
Div(mk.icon(person_outline_sharp, cls="mr-1"), user_info['username'], cls="flex m-1"),
|
||||
Div(mk.icon(alternate_email_filled, cls="mr-1"), user_info['email'], cls="flex m-1"),
|
||||
Div(mk.icon(dark_mode_filled, cls="mr-1"), self.mk_dark_mode(), cls="flex m-1"),
|
||||
Div(A("Logout", cls="btn btn-xs mr-1", href="/logout"), cls="flex justify-center items-center"),
|
||||
tabindex="-1",
|
||||
cls="dropdown-content menu w-52 rounded-box bg-base-300 shadow-xl"
|
||||
),
|
||||
cls="dropdown dropdown-end"
|
||||
)
|
||||
|
||||
def mk_dark_mode(self):
|
||||
return Label(
|
||||
mk.mk(Input(type="checkbox",
|
||||
name='theme',
|
||||
aria_label='Dark',
|
||||
value="dark",
|
||||
checked='true' if self._state.theme == 'dark' else None,
|
||||
cls='theme-controller'),
|
||||
command=self._commands.update_dark_mode()),
|
||||
light_mode_filled,
|
||||
dark_mode_filled,
|
||||
cls="toggle text-base-content"
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -0,0 +1,100 @@
|
||||
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()
|
||||
@@ -5,12 +5,33 @@ from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.utils import merge_classes
|
||||
|
||||
|
||||
class Ids:
|
||||
# Please keep the alphabetical order
|
||||
Root = "mf-root"
|
||||
UserSession = "mf-user_session"
|
||||
|
||||
|
||||
class mk:
|
||||
|
||||
@staticmethod
|
||||
def button(element, command: Command = None, binding: Binding = None, **kwargs):
|
||||
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,
|
||||
can_select=True,
|
||||
@@ -27,6 +48,27 @@ 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:
|
||||
@@ -50,6 +92,6 @@ class mk:
|
||||
|
||||
@staticmethod
|
||||
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
|
||||
ft = mk.manage_command(ft, command)
|
||||
ft = mk.manage_binding(ft, binding, init_binding=init_binding)
|
||||
ft = mk.manage_command(ft, command) if command else ft
|
||||
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
|
||||
return ft
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from myfasthtml.auth.utils import login_user, save_user_info, register_user
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
|
||||
|
||||
class AuthProxy(SingleInstance):
|
||||
def __init__(self, parent, base_url: str = None):
|
||||
super().__init__(parent)
|
||||
self._base_url = base_url
|
||||
|
||||
def login_user(self, email: str, password: str):
|
||||
return login_user(email, password, self._base_url)
|
||||
|
||||
def register_user(self, email: str, username: str, password: str):
|
||||
return register_user(email, username, password, self._base_url)
|
||||
|
||||
def save_user_info(self, access_token: str, user_profile: dict):
|
||||
return save_user_info(access_token, user_profile, self._base_url)
|
||||
@@ -129,6 +129,14 @@ 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:
|
||||
@@ -270,10 +278,10 @@ class Binding:
|
||||
return self
|
||||
|
||||
def get_htmx_params(self):
|
||||
return self.htmx_extra | {
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.Bindings}",
|
||||
"hx-vals": f'{{"b_id": "{self.id}"}}',
|
||||
}
|
||||
} | self.htmx_extra
|
||||
|
||||
def init(self):
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import inspect
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
@@ -29,20 +30,23 @@ class BaseCommand:
|
||||
self.description = description
|
||||
self._htmx_extra = {}
|
||||
self._bindings = []
|
||||
self._ft = None
|
||||
|
||||
# register the command
|
||||
CommandsManager.register(self)
|
||||
|
||||
def get_htmx_params(self):
|
||||
return self._htmx_extra | {
|
||||
return {
|
||||
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
|
||||
"hx-vals": f'{{"c_id": "{self.id}"}}',
|
||||
}
|
||||
"hx-swap": "outerHTML",
|
||||
"hx-vals": {"c_id": f"{self.id}"},
|
||||
} | self._htmx_extra
|
||||
|
||||
def execute(self):
|
||||
def execute(self, client_response: dict = None):
|
||||
raise NotImplementedError
|
||||
|
||||
def htmx(self, target="this", swap="innerHTML"):
|
||||
def htmx(self, target: Optional[str] = "this", swap="outerHTML", trigger=None):
|
||||
# Note that the default value is the same than in get_htmx_params()
|
||||
if target is None:
|
||||
self._htmx_extra["hx-swap"] = "none"
|
||||
elif target != "this":
|
||||
@@ -50,8 +54,12 @@ class BaseCommand:
|
||||
|
||||
if swap is None:
|
||||
self._htmx_extra["hx-swap"] = "none"
|
||||
elif swap != "innerHTML":
|
||||
elif swap != "outerHTML":
|
||||
self._htmx_extra["hx-swap"] = swap
|
||||
|
||||
if trigger is not None:
|
||||
self._htmx_extra["hx-trigger"] = trigger
|
||||
|
||||
return self
|
||||
|
||||
def bind_ft(self, ft):
|
||||
@@ -61,6 +69,7 @@ class BaseCommand:
|
||||
:param ft:
|
||||
:return:
|
||||
"""
|
||||
self._ft = ft
|
||||
htmx = self.get_htmx_params()
|
||||
ft.attrs |= htmx
|
||||
return ft
|
||||
@@ -84,6 +93,16 @@ 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})"
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
@@ -107,10 +126,24 @@ class Command(BaseCommand):
|
||||
def __init__(self, name, description, callback, *args, **kwargs):
|
||||
super().__init__(name, description)
|
||||
self.callback = callback
|
||||
self.callback_parameters = dict(inspect.signature(callback).parameters)
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def execute(self):
|
||||
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 = []
|
||||
|
||||
def binding_result_callback(attr, old, new, results):
|
||||
@@ -119,21 +152,41 @@ class Command(BaseCommand):
|
||||
for data in self._bindings:
|
||||
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||
|
||||
ret = self.callback(*self.args, **self.kwargs)
|
||||
new_kwargs = self.kwargs.copy()
|
||||
if client_response:
|
||||
for k, v in client_response.items():
|
||||
if k in self.callback_parameters:
|
||||
new_kwargs[k] = self._convert(k, v)
|
||||
if 'client_response' in self.callback_parameters:
|
||||
new_kwargs['client_response'] = client_response
|
||||
|
||||
ret = self.callback(*self.args, **new_kwargs)
|
||||
|
||||
for data in self._bindings:
|
||||
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
|
||||
|
||||
# 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 not ret_from_bindings:
|
||||
return ret
|
||||
|
||||
if isinstance(ret, list):
|
||||
return ret + ret_from_bindings
|
||||
if isinstance(ret, (list, tuple)):
|
||||
return list(ret) + ret_from_bindings
|
||||
else:
|
||||
return [ret] + ret_from_bindings
|
||||
|
||||
def __str__(self):
|
||||
return f"Command({self.name})"
|
||||
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
from dbengine.dbengine import DbEngine
|
||||
|
||||
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||
from myfasthtml.core.utils import retrieve_user_info
|
||||
|
||||
|
||||
class DbManager(SingleInstance):
|
||||
def __init__(self, parent, root=".myFastHtmlDb", auto_register: bool = True):
|
||||
super().__init__(parent, auto_register=auto_register)
|
||||
self.db = DbEngine(root=root)
|
||||
|
||||
def save(self, entry, obj):
|
||||
self.db.save(self.get_tenant(), self.get_user(), entry, obj)
|
||||
|
||||
def load(self, entry):
|
||||
return self.db.load(self.get_tenant(), entry)
|
||||
|
||||
def exists_entry(self, entry):
|
||||
return self.db.exists(self.get_tenant(), entry)
|
||||
|
||||
def get_tenant(self):
|
||||
return retrieve_user_info(self._session)["id"]
|
||||
|
||||
def get_user(self):
|
||||
return retrieve_user_info(self._session)["email"]
|
||||
|
||||
|
||||
class DbObject:
|
||||
"""
|
||||
When you set the attribute, it persists in DB
|
||||
It loads from DB at startup
|
||||
"""
|
||||
_initializing = False
|
||||
_forbidden_attrs = {"_initializing", "_db_manager", "_name", "_owner", "_forbidden_attrs"}
|
||||
|
||||
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
|
||||
self._owner = owner
|
||||
self._name = name or self.__class__.__name__
|
||||
self._db_manager = db_manager or DbManager(self._owner)
|
||||
|
||||
self._finalize_initialization()
|
||||
|
||||
@contextmanager
|
||||
def initializing(self):
|
||||
old_state = getattr(self, "_initializing", False)
|
||||
self._initializing = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._finalize_initialization()
|
||||
self._initializing = old_state
|
||||
|
||||
def __setattr__(self, name: str, value: str):
|
||||
if name.startswith("_") or name.startswith("ns") or getattr(self, "_initializing", False):
|
||||
super().__setattr__(name, value)
|
||||
return
|
||||
|
||||
old_value = getattr(self, name, None)
|
||||
if old_value == value:
|
||||
return
|
||||
|
||||
super().__setattr__(name, value)
|
||||
self._save_self()
|
||||
|
||||
def _finalize_initialization(self):
|
||||
if self._db_manager.exists_entry(self._name):
|
||||
props = self._db_manager.load(self._name)
|
||||
self.update(props)
|
||||
else:
|
||||
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")}
|
||||
if props:
|
||||
self._db_manager.save(self._name, props)
|
||||
|
||||
def _get_properties(self):
|
||||
"""
|
||||
Retrieves all the properties of the current object, combining both the properties defined in
|
||||
the class and the instance attributes.
|
||||
:return: A dictionary containing the properties of the object, where keys are property names
|
||||
and values are their corresponding values.
|
||||
"""
|
||||
props = {k: getattr(self, k) for k, v in self.__class__.__dict__.items()} # for dataclass
|
||||
props |= {k: getattr(self, k) for k, v in self.__dict__.items()} # for dataclass
|
||||
props = {k: v for k, v in props.items() if not k.startswith("__")}
|
||||
return props
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
if len(args) > 1:
|
||||
raise ValueError("Only one argument is allowed")
|
||||
|
||||
properties = {}
|
||||
if args:
|
||||
arg = args[0]
|
||||
if not isinstance(arg, (dict, SimpleNamespace)):
|
||||
raise ValueError("Only dict or Expando are allowed as argument")
|
||||
properties |= vars(arg) if isinstance(arg, SimpleNamespace) else arg
|
||||
|
||||
properties |= kwargs
|
||||
|
||||
# save the new state
|
||||
old_state = getattr(self, "_initializing", False)
|
||||
self._initializing = True
|
||||
for k, v in properties.items():
|
||||
if hasattr(self, k) and k not in DbObject._forbidden_attrs: # internal variables cannot be updated
|
||||
setattr(self, k, v)
|
||||
self._save_self()
|
||||
self._initializing = old_state
|
||||
|
||||
def copy(self):
|
||||
as_dict = self._get_properties().copy()
|
||||
as_dict = {k: v for k, v in as_dict.items() if k not in DbObject._forbidden_attrs}
|
||||
return SimpleNamespace(**as_dict)
|
||||
@@ -0,0 +1,218 @@
|
||||
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 **"}
|
||||
}
|
||||
|
||||
|
||||
class DuplicateInstanceError(Exception):
|
||||
def __init__(self, instance):
|
||||
self.instance = instance
|
||||
|
||||
|
||||
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()
|
||||
|
||||
if auto_register:
|
||||
InstancesManager.register(self._session, self)
|
||||
|
||||
def get_session(self) -> dict:
|
||||
return self._session
|
||||
|
||||
def get_id(self) -> str:
|
||||
return self._id
|
||||
|
||||
def get_parent(self) -> Optional['BaseInstance']:
|
||||
return self._parent
|
||||
|
||||
def get_prefix(self) -> str:
|
||||
return self._prefix
|
||||
|
||||
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):
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class InstancesManager:
|
||||
instances = {}
|
||||
|
||||
@staticmethod
|
||||
def register(session: dict, instance: BaseInstance):
|
||||
"""
|
||||
Register an instance in the manager, so that it can be retrieved later.
|
||||
:param session:
|
||||
:param instance:
|
||||
:return:
|
||||
"""
|
||||
key = (InstancesManager.get_session_id(session), instance.get_id())
|
||||
|
||||
if isinstance(instance, SingleInstance) and key in InstancesManager.instances:
|
||||
raise DuplicateInstanceError(instance)
|
||||
|
||||
InstancesManager.instances[key] = instance
|
||||
return instance
|
||||
|
||||
@staticmethod
|
||||
def get(session: dict, instance_id: str):
|
||||
"""
|
||||
Get or create an instance of the given type (from its id)
|
||||
:param session:
|
||||
:param instance_id:
|
||||
:return:
|
||||
"""
|
||||
key = (InstancesManager.get_session_id(session), instance_id)
|
||||
return InstancesManager.instances[key]
|
||||
|
||||
@staticmethod
|
||||
def get_session_id(session):
|
||||
if session is None:
|
||||
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 **")
|
||||
|
||||
@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)
|
||||
@@ -0,0 +1,85 @@
|
||||
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]
|
||||
@@ -0,0 +1,238 @@
|
||||
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,8 +1,11 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from bs4 import Tag
|
||||
from fastcore.xml import FT
|
||||
from fasthtml.fastapp import fast_app
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from starlette.routing import Mount
|
||||
|
||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||
@@ -60,16 +63,47 @@ def merge_classes(*args):
|
||||
|
||||
|
||||
def debug_routes(app):
|
||||
routes = []
|
||||
|
||||
def _clean_endpoint(endpoint):
|
||||
res = str(endpoint).replace("<function ", "").replace(".<locals>", "")
|
||||
return res.split(" at ")[0]
|
||||
|
||||
def _debug_routes(_app, _route, prefix=""):
|
||||
if isinstance(_route, Mount):
|
||||
for sub_route in _route.app.router.routes:
|
||||
_debug_routes(_app, sub_route, prefix=_route.path)
|
||||
else:
|
||||
print(f"path={prefix}{_route.path}, methods={_route.methods}, endpoint={_route.endpoint}")
|
||||
routes.append({
|
||||
"number": len(routes),
|
||||
"app": str(_app),
|
||||
"name": _route.name,
|
||||
"path": _route.path,
|
||||
"full_path": prefix + _route.path,
|
||||
"endpoint": _clean_endpoint(_route.endpoint),
|
||||
"methods": _route.methods if hasattr(_route, "methods") else [],
|
||||
"path_format": _route.path_format,
|
||||
"path_regex": str(_route.path_regex),
|
||||
})
|
||||
|
||||
for route in app.router.routes:
|
||||
_debug_routes(app, route)
|
||||
|
||||
if not routes:
|
||||
print("No routes found.")
|
||||
return
|
||||
|
||||
table = Table(show_header=True, expand=True, header_style="bold")
|
||||
columns = ["number", "name", "full_path", "endpoint", "methods"] # routes[0].keys()
|
||||
for column in columns:
|
||||
table.add_column(column)
|
||||
|
||||
for route in routes:
|
||||
table.add_row(*[str(route[column]) for column in columns])
|
||||
|
||||
console = Console()
|
||||
console.print(table)
|
||||
|
||||
|
||||
def mount_utils(app):
|
||||
"""
|
||||
@@ -158,19 +192,92 @@ def quoted_str(s):
|
||||
return str(s)
|
||||
|
||||
|
||||
def retrieve_user_info(session: dict):
|
||||
if not session:
|
||||
return {
|
||||
"id": "** NOT LOGGED IN **",
|
||||
"email": "** NOT LOGGED IN **",
|
||||
"username": "** NOT LOGGED IN **",
|
||||
"role": [],
|
||||
"user_settings": {}
|
||||
}
|
||||
|
||||
if "user_info" not in session:
|
||||
return {
|
||||
"id": "** UNKNOWN USER **",
|
||||
"email": "** UNKNOWN USER **",
|
||||
"username": "** UNKNOWN USER **",
|
||||
"role": [],
|
||||
"user_settings": {}
|
||||
}
|
||||
|
||||
return session["user_info"]
|
||||
|
||||
|
||||
def debug_session(session):
|
||||
if session is None:
|
||||
return "None"
|
||||
|
||||
if not isinstance(session, dict):
|
||||
return str(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)
|
||||
|
||||
|
||||
def pascal_to_snake(name: str) -> str:
|
||||
"""Convert a PascalCase or CamelCase string to snake_case."""
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
name = name.strip()
|
||||
# Insert underscore before capital letters (except the first one)
|
||||
s1 = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
|
||||
# Handle consecutive capital letters (like 'HTTPServer' -> 'http_server')
|
||||
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
|
||||
return s2.lower()
|
||||
|
||||
|
||||
def snake_to_pascal(name: str) -> str:
|
||||
"""Convert a snake_case string to PascalCase."""
|
||||
if name is None:
|
||||
return None
|
||||
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return ""
|
||||
|
||||
# Split on underscores and capitalize each part
|
||||
parts = name.split('_')
|
||||
return ''.join(word.capitalize() for word in parts if word)
|
||||
|
||||
|
||||
@utils_rt(Routes.Commands)
|
||||
def post(session, c_id: str):
|
||||
def post(session, c_id: str, client_response: dict = None):
|
||||
"""
|
||||
Default routes for all commands.
|
||||
:param session:
|
||||
:param c_id:
|
||||
:param c_id: id of the command set
|
||||
:param client_response: extra data received from the client (from the browser)
|
||||
:return:
|
||||
"""
|
||||
logger.debug(f"Entering {Routes.Commands} with {session=}, {c_id=}")
|
||||
client_response.pop("c_id", None)
|
||||
logger.debug(f"Entering {Routes.Commands} with session='{debug_session(session)}', {c_id=}, {client_response=}")
|
||||
from myfasthtml.core.commands import CommandsManager
|
||||
command = CommandsManager.get_command(c_id)
|
||||
if command:
|
||||
return command.execute()
|
||||
logger.debug(f"Executing command {command.name}.")
|
||||
return command.execute(client_response)
|
||||
|
||||
raise ValueError(f"Command with ID '{c_id}' not found.")
|
||||
|
||||
@@ -188,6 +295,7 @@ def post(session, b_id: str, values: dict):
|
||||
from myfasthtml.core.bindings import BindingsManager
|
||||
binding = BindingsManager.get_binding(b_id)
|
||||
if binding:
|
||||
return binding.update(values)
|
||||
res = binding.update(values)
|
||||
return res
|
||||
|
||||
raise ValueError(f"Binding with ID '{b_id}' not found.")
|
||||
|
||||
@@ -17,5 +17,15 @@ Update the root folder in `update_icons.py` to point to the root folder of the i
|
||||
|
||||
##
|
||||
```sh
|
||||
python update_icons.py
|
||||
python manage_icons.py --help
|
||||
```
|
||||
|
||||
To list
|
||||
```sh
|
||||
python manage_icons.py list
|
||||
```
|
||||
|
||||
To generate icons
|
||||
```sh
|
||||
python manage_icons.py generate --no-dry-run --suppress-suffix
|
||||
```
|
||||
+789
-1671
File diff suppressed because it is too large
Load Diff
+1898
-8899
File diff suppressed because it is too large
Load Diff
+1609
-1609
File diff suppressed because one or more lines are too long
+2691
-30892
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+696
-8645
File diff suppressed because it is too large
Load Diff
+1332
-5287
File diff suppressed because one or more lines are too long
@@ -0,0 +1,154 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
ROOT_FOLDER = "/home/kodjo/Dev/MyDocManager/src/frontend/node_modules/@sicons"
|
||||
MAX_SIZE = 2000000
|
||||
|
||||
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')
|
||||
s2 = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s1)
|
||||
return s2.lower()
|
||||
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
|
||||
def list_sources(source_folder: str):
|
||||
return os.listdir(source_folder)
|
||||
|
||||
|
||||
def list_icons_from_source(source_folder: str, source: str):
|
||||
res = []
|
||||
for f in os.listdir(f"{source_folder}/{source}"):
|
||||
if f.endswith(".svg"):
|
||||
res.append(f)
|
||||
return res
|
||||
|
||||
|
||||
def read_content(source_folder: str, source: str, file_name: str):
|
||||
with open(f"{source_folder}/{source}/{file_name}", "r") as f:
|
||||
return f.read().strip()
|
||||
|
||||
|
||||
def get_dir_size(path: str | Path) -> int:
|
||||
p = Path(path)
|
||||
if p.is_file():
|
||||
return p.stat().st_size
|
||||
elif p.is_dir():
|
||||
return sum(f.stat().st_size for f in p.rglob('*') if f.is_file())
|
||||
else:
|
||||
raise FileNotFoundError(f"Path not found: {path}")
|
||||
|
||||
|
||||
def sizeof_fmt(num, suffix="B"):
|
||||
for unit in ["", "K", "M", "G", "T"]:
|
||||
if abs(num) < 1024.0:
|
||||
return f"{num:3.1f}{unit}{suffix}"
|
||||
num /= 1024.0
|
||||
return f"{num:.1f}P{suffix}"
|
||||
|
||||
|
||||
def init_buffer(source_folder: str, source: str):
|
||||
buffer = ""
|
||||
readme_file_path = f"{source_folder}/{source}/README.md"
|
||||
if os.path.exists(readme_file_path):
|
||||
with open(readme_file_path, "r") as f_readme:
|
||||
for line in f_readme:
|
||||
if line.startswith("#"):
|
||||
buffer += line
|
||||
else:
|
||||
buffer += f"# {line}"
|
||||
buffer += "\n\n"
|
||||
|
||||
buffer += "from fastcore.basics import NotStr\n\n"
|
||||
return buffer
|
||||
|
||||
|
||||
def flush(dry_run, suppress_suffix, source_folder: str, target_folder: str, buffer: str, size: int, part: int, source: str):
|
||||
suffix = '' if suppress_suffix else f"_test"
|
||||
outfile = f"{source}{suffix}.py" if part == 0 else f"{source}_p{part}{suffix}.py"
|
||||
if not dry_run:
|
||||
output_path = f"{target_folder}/{outfile}" if part == 0 else f"{target_folder}/{outfile}"
|
||||
with open(output_path, "w") as f:
|
||||
f.write(buffer)
|
||||
|
||||
typer.echo(f" Generated {source} as {outfile} ({sizeof_fmt(size)}, max={sizeof_fmt(MAX_SIZE)})")
|
||||
return init_buffer(source_folder, source), 0, part + 1
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_icons(
|
||||
source: str = typer.Argument(None, help="The source file to list icons from"),
|
||||
source_folder: str = typer.Option(ROOT_FOLDER, help="The source folder containing icons"),
|
||||
count: bool = typer.Option(False, help="Counts the number of items"),
|
||||
size: bool = typer.Option(False, help="Gets the size of the items"),
|
||||
):
|
||||
res = []
|
||||
if source:
|
||||
res.extend(list_icons_from_source(source_folder, source))
|
||||
else:
|
||||
res.extend(list_sources(source_folder))
|
||||
|
||||
if count:
|
||||
typer.echo(len(res))
|
||||
return
|
||||
|
||||
if size:
|
||||
path = f"{source_folder}/{source}" if source else f"{source_folder}"
|
||||
size = get_dir_size(path)
|
||||
typer.echo(sizeof_fmt(size))
|
||||
return
|
||||
|
||||
for r in res:
|
||||
typer.echo(r)
|
||||
|
||||
|
||||
@app.command("generate")
|
||||
def generate_icons(
|
||||
source: str = typer.Argument(None, help="The source file to list icons from"),
|
||||
source_folder: str = typer.Option(ROOT_FOLDER, help="The source folder containing icons"),
|
||||
target_folder: str = typer.Option(".", help="The folder where to create the python files."),
|
||||
top: int = typer.Option(0, help="The number of top items to generate"),
|
||||
dry_run: bool = typer.Option(True, help="Does not generate the icons"),
|
||||
suppress_suffix: bool = typer.Option(False, help="Does not add the suffix to the icon names"),
|
||||
):
|
||||
sources = [source] if source else list_sources(source_folder)
|
||||
for current_source in sources:
|
||||
typer.echo(f"Generating icons for {current_source}")
|
||||
buffer = init_buffer(source_folder, current_source)
|
||||
size = 0
|
||||
part = 0
|
||||
for index, svg_file in enumerate(list_icons_from_source(source_folder, current_source)):
|
||||
|
||||
if 0 < top <= index:
|
||||
break
|
||||
|
||||
icon_name = os.path.splitext(os.path.basename(svg_file))[0]
|
||||
svg_content = read_content(source_folder, current_source, svg_file)
|
||||
svg_content = svg_content.replace("<svg ", f'<svg name="{current_source}-{icon_name}" ').replace("\n", "")
|
||||
|
||||
content = f"{pascal_to_snake(icon_name)} = NotStr('''{svg_content}''')"
|
||||
buffer += f"{content}\n"
|
||||
size += len(content)
|
||||
|
||||
if size > MAX_SIZE:
|
||||
buffer, size, part = flush(dry_run, suppress_suffix, source_folder, target_folder, buffer, size, part,
|
||||
current_source)
|
||||
|
||||
flush(dry_run, suppress_suffix, source_folder, target_folder, buffer, size, part, current_source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
+3931
-19054
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1518
-10862
File diff suppressed because it is too large
Load Diff
+23
-11
@@ -9,6 +9,8 @@ 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")
|
||||
@@ -30,8 +32,10 @@ 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,
|
||||
**kwargs) -> Any:
|
||||
"""
|
||||
Creates and configures a FastHtml application with optional support for daisyUI themes and
|
||||
@@ -39,22 +43,20 @@ def create_app(daisyui: Optional[bool] = True,
|
||||
|
||||
:param daisyui: Flag to enable or disable inclusion of daisyUI-related assets for styling.
|
||||
Defaults to False.
|
||||
:type daisyui: Optional[bool]
|
||||
|
||||
:param protect_routes: Flag to enable or disable routes protection based on authentication.
|
||||
Defaults to True.
|
||||
:type protect_routes: Optional[bool]
|
||||
|
||||
:param mount_auth_app: Flag to enable or disable mounting of authentication routes.
|
||||
Defaults to False.
|
||||
:type mount_auth_app: Optional[bool]
|
||||
|
||||
:param base_url: Url to use for the application (used by the auth APIs)
|
||||
:param kwargs: Arbitrary keyword arguments forwarded to the application initialization logic.
|
||||
|
||||
:return: A tuple containing the FastHtml application instance and the associated router.
|
||||
:rtype: Any
|
||||
"""
|
||||
hdrs = [Link(href="/myfasthtml/myfasthtml.css", rel="stylesheet", type="text/css")]
|
||||
hdrs = [
|
||||
Link(href="/myfasthtml/myfasthtml.css", rel="stylesheet", type="text/css"),
|
||||
Script(src="/myfasthtml/myfasthtml.js"),
|
||||
]
|
||||
|
||||
if daisyui:
|
||||
hdrs += [
|
||||
@@ -63,15 +65,21 @@ 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)
|
||||
|
||||
# remove the global static files routes
|
||||
static_route_exts_get = app.routes.pop(0)
|
||||
original_routes = app.routes[:]
|
||||
app.routes.clear()
|
||||
|
||||
# Serve assets
|
||||
@app.get("/myfasthtml/{filename:path}.{ext:static}")
|
||||
def serve_asset(filename: str, ext: str):
|
||||
def serve_assets(filename: str, ext: str):
|
||||
path = filename + "." + ext
|
||||
try:
|
||||
content = get_asset_content(path)
|
||||
@@ -86,13 +94,17 @@ def create_app(daisyui: Optional[bool] = True,
|
||||
return Response(f"Asset not found: {path}", status_code=404)
|
||||
|
||||
# and put it back after the myfasthtml static files routes
|
||||
app.routes.append(static_route_exts_get)
|
||||
for r in original_routes:
|
||||
app.routes.append(r)
|
||||
|
||||
# route the commands and the bindings
|
||||
app.mount("/myfasthtml", utils_app)
|
||||
|
||||
if mount_auth_app:
|
||||
# Setup authentication routes
|
||||
setup_auth_routes(app, rt)
|
||||
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
|
||||
|
||||
return app, rt
|
||||
|
||||
@@ -90,6 +90,14 @@ class Empty(ChildrenPredicate):
|
||||
return len(actual.children) == 0 and len(actual.attrs) == 0
|
||||
|
||||
|
||||
class NoChildren(ChildrenPredicate):
|
||||
def __init__(self):
|
||||
super().__init__(None)
|
||||
|
||||
def validate(self, actual):
|
||||
return len(actual.children) == 0
|
||||
|
||||
|
||||
class AttributeForbidden(ChildrenPredicate):
|
||||
"""
|
||||
To validate that an attribute is not present in an element.
|
||||
@@ -402,3 +410,59 @@ def matches(actual, expected, path=""):
|
||||
_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
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def session():
|
||||
return {
|
||||
"user_info": {
|
||||
'email': 'test@myfasthtml.com',
|
||||
'username': 'test_user',
|
||||
'roles': ['admin'],
|
||||
'user_settings': {},
|
||||
'id': 'test_user_id',
|
||||
'created_at': '2025-11-10T15:52:59.006213',
|
||||
'updated_at': '2025-11-10T15:52:59.006213'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def root_instance(session):
|
||||
return SingleInstance(None, session, "TestRoot")
|
||||
@@ -0,0 +1,126 @@
|
||||
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
|
||||
from myfasthtml.test.matcher import matches, NoChildren
|
||||
from .conftest import session
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def tabs_manager(root_instance):
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
yield TabsManager(root_instance)
|
||||
|
||||
InstancesManager.reset()
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
|
||||
class TestTabsManagerBehaviour:
|
||||
def test_tabs_manager_is_registered(self, session, tabs_manager):
|
||||
from_instance_manager = InstancesManager.get(session, tabs_manager.get_id())
|
||||
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"))
|
||||
|
||||
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]["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]
|
||||
assert tabs_manager.get_state().active_tab == tab_id
|
||||
|
||||
def test_i_can_add_multiple_tabs(self, tabs_manager):
|
||||
tab_id1 = tabs_manager.add_tab("Users", Div("Content 1"))
|
||||
tab_id2 = tabs_manager.add_tab("User2", Div("Content 2"))
|
||||
|
||||
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:
|
||||
def test_i_can_render_when_no_tabs(self, tabs_manager):
|
||||
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"
|
||||
),
|
||||
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"))
|
||||
tabs_manager.add_tab("Users3", Div("Content 3"))
|
||||
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(
|
||||
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"
|
||||
),
|
||||
id=tabs_manager.get_id(),
|
||||
)
|
||||
assert matches(res, expected)
|
||||
+162
-61
@@ -2,10 +2,10 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fasthtml.components import Button
|
||||
from fasthtml.components import Button, Div
|
||||
from myutils.observable import make_observable, bind
|
||||
|
||||
from myfasthtml.core.commands import Command, CommandsManager
|
||||
from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand
|
||||
from myfasthtml.core.constants import ROUTE_ROOT, Routes
|
||||
from myfasthtml.test.matcher import matches
|
||||
|
||||
@@ -24,72 +24,173 @@ def reset_command_manager():
|
||||
CommandsManager.reset()
|
||||
|
||||
|
||||
def test_i_can_create_a_command_with_no_params():
|
||||
command = Command('test', 'Command description', callback)
|
||||
assert command.id is not None
|
||||
assert command.name == 'test'
|
||||
assert command.description == 'Command description'
|
||||
assert command.execute() == "Hello World"
|
||||
class TestCommandDefault:
|
||||
|
||||
def test_i_can_create_a_command_with_no_params(self):
|
||||
command = Command('test', 'Command description', callback)
|
||||
assert command.id is not None
|
||||
assert command.name == 'test'
|
||||
assert command.description == 'Command description'
|
||||
assert command.execute() == "Hello World"
|
||||
|
||||
def test_command_are_registered(self):
|
||||
command = Command('test', 'Command description', callback)
|
||||
assert CommandsManager.commands.get(str(command.id)) is command
|
||||
|
||||
|
||||
def test_command_are_registered():
|
||||
command = Command('test', 'Command description', callback)
|
||||
assert CommandsManager.commands.get(str(command.id)) is command
|
||||
class TestCommandBind:
|
||||
|
||||
def test_i_can_bind_a_command_to_an_element(self):
|
||||
command = Command('test', 'Command description', callback)
|
||||
elt = Button()
|
||||
updated = command.bind_ft(elt)
|
||||
|
||||
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}")
|
||||
|
||||
assert matches(updated, expected)
|
||||
|
||||
def test_i_can_suppress_swapping_with_target_attr(self):
|
||||
command = Command('test', 'Command description', callback).htmx(target=None)
|
||||
elt = Button()
|
||||
updated = command.bind_ft(elt)
|
||||
|
||||
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="none")
|
||||
|
||||
assert matches(updated, expected)
|
||||
|
||||
def test_i_can_bind_a_command_to_an_observable(self):
|
||||
data = Data("hello")
|
||||
|
||||
def on_data_change(old, new):
|
||||
return old, new
|
||||
|
||||
def another_callback():
|
||||
data.value = "new value"
|
||||
return "another callback result"
|
||||
|
||||
make_observable(data)
|
||||
bind(data, "value", on_data_change)
|
||||
command = Command('test', 'Command description', another_callback).bind(data)
|
||||
|
||||
res = command.execute()
|
||||
|
||||
assert res == ["another callback result", ("hello", "new value")]
|
||||
|
||||
def test_i_can_bind_a_command_to_an_observable_2(self):
|
||||
data = Data("hello")
|
||||
|
||||
def on_data_change(old, new):
|
||||
return old, new
|
||||
|
||||
def another_callback():
|
||||
data.value = "new value"
|
||||
return ["another 1", "another 2"]
|
||||
|
||||
make_observable(data)
|
||||
bind(data, "value", on_data_change)
|
||||
command = Command('test', 'Command description', another_callback).bind(data)
|
||||
|
||||
res = command.execute()
|
||||
|
||||
assert res == ["another 1", "another 2", ("hello", "new value")]
|
||||
|
||||
def test_by_default_swap_is_set_to_outer_html(self):
|
||||
command = Command('test', 'Command description', callback)
|
||||
elt = Button()
|
||||
updated = command.bind_ft(elt)
|
||||
|
||||
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="outerHTML")
|
||||
|
||||
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
|
||||
])
|
||||
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."""
|
||||
|
||||
def another_callback():
|
||||
return return_values
|
||||
|
||||
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_i_can_bind_a_command_to_an_element():
|
||||
command = Command('test', 'Command description', callback)
|
||||
elt = Button()
|
||||
updated = command.bind_ft(elt)
|
||||
class TestCommandExecute:
|
||||
|
||||
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}")
|
||||
def test_i_can_create_a_command_with_no_params(self):
|
||||
command = Command('test', 'Command description', callback)
|
||||
assert command.id is not None
|
||||
assert command.name == 'test'
|
||||
assert command.description == 'Command description'
|
||||
assert command.execute() == "Hello World"
|
||||
|
||||
assert matches(updated, expected)
|
||||
def test_i_can_execute_a_command_with_closed_parameter(self):
|
||||
"""The parameter is given when the command is created."""
|
||||
|
||||
def callback_with_param(param):
|
||||
return f"Hello {param}"
|
||||
|
||||
command = Command('test', 'Command description', callback_with_param, "world")
|
||||
assert command.execute() == "Hello world"
|
||||
|
||||
def test_i_can_execute_a_command_with_open_parameter(self):
|
||||
"""The parameter is given by the browser, when the command is executed."""
|
||||
|
||||
def callback_with_param(name):
|
||||
return f"Hello {name}"
|
||||
|
||||
command = Command('test', 'Command description', callback_with_param)
|
||||
assert command.execute(client_response={"name": "world"}) == "Hello world"
|
||||
|
||||
def test_i_can_convert_arg_in_execute(self):
|
||||
"""The parameter is given by the browser, when the command is executed."""
|
||||
|
||||
def callback_with_param(number: int):
|
||||
assert isinstance(number, int)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_i_can_suppress_swapping_with_target_attr():
|
||||
command = Command('test', 'Command description', callback).htmx(target=None)
|
||||
elt = Button()
|
||||
updated = command.bind_ft(elt)
|
||||
class TestLambaCommand:
|
||||
|
||||
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="none")
|
||||
def test_i_can_create_a_command_from_lambda(self):
|
||||
command = LambdaCommand(lambda resp: "Hello World")
|
||||
assert command.execute() == "Hello World"
|
||||
|
||||
assert matches(updated, expected)
|
||||
|
||||
|
||||
def test_i_can_bind_a_command_to_an_observable():
|
||||
data = Data("hello")
|
||||
|
||||
def on_data_change(old, new):
|
||||
return old, new
|
||||
|
||||
def another_callback():
|
||||
data.value = "new value"
|
||||
return "another callback result"
|
||||
|
||||
make_observable(data)
|
||||
bind(data, "value", on_data_change)
|
||||
command = Command('test', 'Command description', another_callback).bind(data)
|
||||
|
||||
res = command.execute()
|
||||
|
||||
assert res == ["another callback result", ("hello", "new value")]
|
||||
|
||||
|
||||
def test_i_can_bind_a_command_to_an_observable_2():
|
||||
data = Data("hello")
|
||||
|
||||
def on_data_change(old, new):
|
||||
return old, new
|
||||
|
||||
def another_callback():
|
||||
data.value = "new value"
|
||||
return ["another 1", "another 2"]
|
||||
|
||||
make_observable(data)
|
||||
bind(data, "value", on_data_change)
|
||||
command = Command('test', 'Command description', another_callback).bind(data)
|
||||
|
||||
res = command.execute()
|
||||
|
||||
assert res == ["another 1", "another 2", ("hello", "new value")]
|
||||
def test_by_default_target_is_none(self):
|
||||
command = LambdaCommand(lambda resp: "Hello World")
|
||||
assert command.get_htmx_params()["hx-swap"] == "none"
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.dbmanager import DbManager, DbObject
|
||||
from myfasthtml.core.instances import SingleInstance, BaseInstance
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def session():
|
||||
return {
|
||||
"user_info": {
|
||||
"id": "test_tenant_id",
|
||||
"email": "test@email.com",
|
||||
"username": "test user",
|
||||
"role": [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parent(session):
|
||||
return SingleInstance(session=session, _id="test_parent_id")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_manager(parent):
|
||||
shutil.rmtree("TestDb", ignore_errors=True)
|
||||
db_manager_instance = DbManager(parent, root="TestDb", auto_register=False)
|
||||
|
||||
yield db_manager_instance
|
||||
|
||||
shutil.rmtree("TestDb", ignore_errors=True)
|
||||
|
||||
|
||||
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):
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
|
||||
with self.initializing():
|
||||
self.value: str = "hello"
|
||||
self.number: int = 42
|
||||
self.none_value: None = None
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
|
||||
props = dummy._get_properties()
|
||||
|
||||
in_db = db_manager.load("DummyObject")
|
||||
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
||||
assert simplify(in_db) == {"value": "hello", "number": 42, "none_value": None}
|
||||
assert len(history) == 1
|
||||
|
||||
|
||||
def test_i_can_init_from_dataclass(parent, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
none_value: None = None
|
||||
|
||||
DummyObject(parent)
|
||||
|
||||
in_db = db_manager.load("DummyObject")
|
||||
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
|
||||
assert simplify(in_db) == {"value": "hello", "number": 42, "none_value": None}
|
||||
assert len(history) == 1
|
||||
|
||||
|
||||
def test_i_can_init_from_db_with(parent, db_manager):
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
|
||||
with self.initializing():
|
||||
self.value: str = "hello"
|
||||
self.number: int = 42
|
||||
|
||||
# insert other values in db
|
||||
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
|
||||
assert dummy.value == "other_value"
|
||||
assert dummy.number == 34
|
||||
|
||||
|
||||
def test_i_can_init_from_db_with_dataclass(parent, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
|
||||
# insert other values in db
|
||||
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
|
||||
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):
|
||||
@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)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
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):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
dummy.value = "other_value"
|
||||
in_db_1 = db_manager.load("DummyObject")
|
||||
|
||||
dummy.value = "other_value"
|
||||
in_db_2 = db_manager.load("DummyObject")
|
||||
|
||||
assert in_db_1["__parent__"] == in_db_2["__parent__"]
|
||||
|
||||
|
||||
def test_i_can_update(parent, db_manager):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
clone = dummy.copy()
|
||||
|
||||
clone.number = 34
|
||||
clone.value = "other_value"
|
||||
clone.other_attr = "some_value"
|
||||
|
||||
dummy.update(clone)
|
||||
|
||||
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
|
||||
|
||||
|
||||
def test_forbidden_attributes_are_not_the_copy(parent, db_manager):
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
|
||||
with self.initializing():
|
||||
self.value: str = "hello"
|
||||
self.number: int = 42
|
||||
self.none_value: None = None
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
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):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
none_value: None = None
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
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):
|
||||
@dataclass
|
||||
class DummyObject(DbObject):
|
||||
def __init__(self, owner: BaseInstance):
|
||||
super().__init__(owner, "DummyObject", db_manager)
|
||||
|
||||
value: str = "hello"
|
||||
number: int = 42
|
||||
none_value: None = None
|
||||
|
||||
dummy = DummyObject(parent)
|
||||
|
||||
dummy.update(_owner="other_value")
|
||||
|
||||
assert dummy._owner is parent
|
||||
@@ -0,0 +1,399 @@
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.instances import (
|
||||
BaseInstance,
|
||||
SingleInstance,
|
||||
MultipleInstance,
|
||||
InstancesManager,
|
||||
DuplicateInstanceError,
|
||||
special_session,
|
||||
Ids,
|
||||
RootInstance
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_instances():
|
||||
"""Reset instances before each test to ensure isolation."""
|
||||
InstancesManager.instances.clear()
|
||||
yield
|
||||
InstancesManager.instances.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session():
|
||||
"""Create a test session."""
|
||||
return {"user_info": {"id": "test-user-123"}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def another_session():
|
||||
"""Create another test session."""
|
||||
return {"user_info": {"id": "test-user-456"}}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def root_instance(session):
|
||||
"""Create a root instance for testing."""
|
||||
return SingleInstance(parent=None, session=session, _id="test-root")
|
||||
|
||||
|
||||
# Example subclasses for testing
|
||||
class SubSingleInstance(SingleInstance):
|
||||
"""Example subclass of SingleInstance with simplified signature."""
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
|
||||
class SubMultipleInstance(MultipleInstance):
|
||||
"""Example subclass of MultipleInstance with custom parameter."""
|
||||
|
||||
def __init__(self, parent, _id=None, custom_param=None):
|
||||
super().__init__(parent=parent, _id=_id)
|
||||
self.custom_param = custom_param
|
||||
|
||||
|
||||
class TestBaseInstance:
|
||||
|
||||
def test_i_can_create_a_base_instance_with_positional_args(self, session, root_instance):
|
||||
"""Test that a BaseInstance can be created with positional arguments."""
|
||||
instance = BaseInstance(root_instance, session, "test_id")
|
||||
|
||||
assert instance is not None
|
||||
assert instance.get_id() == "test_id"
|
||||
assert instance.get_session() == session
|
||||
assert instance.get_parent() == root_instance
|
||||
|
||||
def test_i_can_create_a_base_instance_with_kwargs(self, session, root_instance):
|
||||
"""Test that a BaseInstance can be created with keyword arguments."""
|
||||
instance = BaseInstance(parent=root_instance, session=session, _id="test_id")
|
||||
|
||||
assert instance is not None
|
||||
assert instance.get_id() == "test_id"
|
||||
assert instance.get_session() == session
|
||||
assert instance.get_parent() == root_instance
|
||||
|
||||
def test_i_can_create_a_base_instance_with_mixed_args(self, session, root_instance):
|
||||
"""Test that a BaseInstance can be created with mixed positional and keyword arguments."""
|
||||
instance = BaseInstance(root_instance, session=session, _id="test_id")
|
||||
|
||||
assert instance is not None
|
||||
assert instance.get_id() == "test_id"
|
||||
assert instance.get_session() == session
|
||||
assert instance.get_parent() == root_instance
|
||||
|
||||
def test_i_can_retrieve_the_same_instance_when_using_same_session_and_id(self, session, root_instance):
|
||||
"""Test that creating an instance with same session and id returns the existing instance."""
|
||||
instance1 = BaseInstance(root_instance, session, "same_id")
|
||||
instance2 = BaseInstance(root_instance, session, "same_id")
|
||||
|
||||
assert instance1 is instance2
|
||||
|
||||
def test_i_can_control_instances_registration(self, session, root_instance):
|
||||
"""Test that auto_register=False prevents automatic registration."""
|
||||
BaseInstance(parent=root_instance, session=session, _id="test_id", auto_register=False)
|
||||
|
||||
session_id = InstancesManager.get_session_id(session)
|
||||
key = (session_id, "test_id")
|
||||
|
||||
assert key not in InstancesManager.instances
|
||||
|
||||
def test_i_can_have_different_instances_for_different_sessions(self, session, another_session, root_instance):
|
||||
"""Test that different sessions can have instances with the same id."""
|
||||
root_instance2 = SingleInstance(parent=None, session=another_session, _id="test-root")
|
||||
|
||||
instance1 = BaseInstance(root_instance, session, "same_id")
|
||||
instance2 = BaseInstance(root_instance2, another_session, "same_id")
|
||||
|
||||
assert instance1 is not instance2
|
||||
assert instance1.get_session() == session
|
||||
assert instance2.get_session() == another_session
|
||||
|
||||
def test_i_can_create_instance_with_parent_only(self, session, root_instance):
|
||||
"""Test that session can be extracted from parent when not provided."""
|
||||
instance = BaseInstance(parent=root_instance, _id="test_id")
|
||||
|
||||
assert instance.get_session() == root_instance.get_session()
|
||||
assert instance.get_parent() == root_instance
|
||||
|
||||
def test_i_cannot_create_instance_without_parent_or_session(self):
|
||||
"""Test that creating an instance without parent or session raises TypeError."""
|
||||
with pytest.raises(TypeError, match="Either session or parent must be provided"):
|
||||
BaseInstance(None, _id="test_id")
|
||||
|
||||
def test_i_can_get_auto_generated_id(self, session, root_instance):
|
||||
"""Test that if _id is not provided, an ID is auto-generated via compute_id()."""
|
||||
instance = BaseInstance(parent=root_instance, session=session)
|
||||
|
||||
assert instance.get_id() is not None
|
||||
assert instance.get_id().startswith("mf-base_instance-")
|
||||
|
||||
def test_i_can_get_prefix_from_class_name(self, session):
|
||||
"""Test that get_prefix() returns the correct snake_case prefix."""
|
||||
prefix = BaseInstance(None, session).get_prefix()
|
||||
|
||||
assert prefix == "mf-base_instance"
|
||||
|
||||
|
||||
class TestSingleInstance:
|
||||
|
||||
def test_i_can_create_a_single_instance(self, session, root_instance):
|
||||
"""Test that a SingleInstance can be created."""
|
||||
instance = SingleInstance(parent=root_instance, session=session)
|
||||
|
||||
assert instance is not None
|
||||
assert instance.get_id() == "mf-single_instance"
|
||||
assert instance.get_session() == session
|
||||
assert instance.get_parent() == root_instance
|
||||
|
||||
def test_i_can_create_single_instance_with_positional_args(self, session, root_instance):
|
||||
"""Test that a SingleInstance can be created with positional arguments."""
|
||||
instance = SingleInstance(root_instance, session, "custom_id")
|
||||
|
||||
assert instance is not None
|
||||
assert instance.get_id() == "custom_id"
|
||||
assert instance.get_session() == session
|
||||
assert instance.get_parent() == root_instance
|
||||
|
||||
def test_the_same_instance_is_returned(self, session):
|
||||
"""Test that single instance is cached and returned on subsequent calls."""
|
||||
instance1 = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||
instance2 = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||
|
||||
assert instance1 is instance2
|
||||
|
||||
def test_i_cannot_create_duplicate_single_instance(self, session):
|
||||
"""Test that creating a duplicate SingleInstance raises DuplicateInstanceError."""
|
||||
instance = SingleInstance(parent=None, session=session, _id="unique_id")
|
||||
|
||||
with pytest.raises(DuplicateInstanceError):
|
||||
InstancesManager.register(session, instance)
|
||||
|
||||
def test_i_can_retrieve_existing_single_instance(self, session):
|
||||
"""Test that attempting to create an existing SingleInstance returns the same instance."""
|
||||
instance1 = SingleInstance(parent=None, session=session, _id="same_id")
|
||||
instance2 = SingleInstance(parent=None, session=session, _id="same_id", auto_register=False)
|
||||
|
||||
assert instance1 is instance2
|
||||
|
||||
def test_i_can_get_auto_computed_id_for_single_instance(self, session):
|
||||
"""Test that the default ID equals prefix for SingleInstance."""
|
||||
instance = SingleInstance(parent=None, session=session)
|
||||
|
||||
assert instance.get_id() == "mf-single_instance"
|
||||
assert instance.get_prefix() == "mf-single_instance"
|
||||
|
||||
|
||||
class TestSingleInstanceSubclass:
|
||||
|
||||
def test_i_can_create_subclass_of_single_instance(self, root_instance):
|
||||
"""Test that a subclass of SingleInstance works correctly."""
|
||||
instance = SubSingleInstance(root_instance)
|
||||
|
||||
assert instance is not None
|
||||
assert isinstance(instance, SingleInstance)
|
||||
assert isinstance(instance, SubSingleInstance)
|
||||
|
||||
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
|
||||
"""Test that subclass with simplified signature works correctly."""
|
||||
instance = SubSingleInstance(root_instance)
|
||||
|
||||
assert instance.get_parent() == root_instance
|
||||
assert instance.get_session() == root_instance.get_session()
|
||||
assert instance.get_id() == "mf-sub_single_instance"
|
||||
assert instance.get_prefix() == "mf-sub_single_instance"
|
||||
|
||||
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
|
||||
"""Test that cache works for subclasses."""
|
||||
instance1 = SubSingleInstance(root_instance)
|
||||
instance2 = SubSingleInstance(root_instance)
|
||||
|
||||
assert instance1 is instance2
|
||||
|
||||
|
||||
class TestMultipleInstance:
|
||||
|
||||
def test_i_can_create_multiple_instances_with_same_prefix(self, session, root_instance):
|
||||
"""Test that multiple MultipleInstance objects can be created with the same prefix."""
|
||||
instance1 = MultipleInstance(parent=root_instance, session=session)
|
||||
instance2 = MultipleInstance(parent=root_instance, session=session)
|
||||
|
||||
assert instance1 is not instance2
|
||||
assert instance1.get_id() != instance2.get_id()
|
||||
assert instance1.get_id().startswith("mf-multiple_instance-")
|
||||
assert instance2.get_id().startswith("mf-multiple_instance-")
|
||||
|
||||
def test_i_can_have_auto_generated_unique_ids(self, session, root_instance):
|
||||
"""Test that each MultipleInstance receives a unique auto-generated ID."""
|
||||
instances = [MultipleInstance(parent=root_instance, session=session) for _ in range(5)]
|
||||
ids = [inst.get_id() for inst in instances]
|
||||
|
||||
# All IDs should be unique
|
||||
assert len(ids) == len(set(ids))
|
||||
|
||||
# All IDs should start with the prefix
|
||||
assert all(id.startswith("mf-multiple_instance-") for id in ids)
|
||||
|
||||
def test_i_can_provide_custom_id_to_multiple_instance(self, session, root_instance):
|
||||
"""Test that a custom _id can be provided to MultipleInstance."""
|
||||
custom_id = "custom-instance-id"
|
||||
instance = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||
|
||||
assert instance.get_id() == custom_id
|
||||
|
||||
def test_i_can_retrieve_multiple_instance_by_custom_id(self, session, root_instance):
|
||||
"""Test that a MultipleInstance with custom _id can be retrieved from cache."""
|
||||
custom_id = "custom-instance-id"
|
||||
instance1 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||
instance2 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
|
||||
|
||||
assert instance1 is instance2
|
||||
|
||||
def test_key_prefixed_by_underscore_uses_the_parent_id_as_prefix(self, root_instance):
|
||||
"""Test that key prefixed by underscore uses the parent id as prefix."""
|
||||
instance = MultipleInstance(parent=root_instance, _id="-test_id")
|
||||
|
||||
assert instance.get_id() == f"{root_instance.get_id()}-test_id"
|
||||
|
||||
def test_no_parent_id_as_prefix_if_parent_is_none(self, session, root_instance):
|
||||
"""Test that key prefixed by underscore does not use the parent id as prefix if parent is None."""
|
||||
instance = MultipleInstance(parent=None, session=session, _id="-test_id")
|
||||
|
||||
assert instance.get_id() == "-test_id"
|
||||
|
||||
|
||||
class TestMultipleInstanceSubclass:
|
||||
|
||||
def test_i_can_create_subclass_of_multiple_instance(self, root_instance):
|
||||
"""Test that a subclass of MultipleInstance works correctly."""
|
||||
instance = SubMultipleInstance(root_instance, custom_param="test")
|
||||
|
||||
assert instance is not None
|
||||
assert isinstance(instance, MultipleInstance)
|
||||
assert isinstance(instance, SubMultipleInstance)
|
||||
assert instance.custom_param == "test"
|
||||
|
||||
def test_i_can_create_multiple_subclass_instances_with_auto_generated_ids(self, root_instance):
|
||||
"""Test that multiple instances of subclass can be created with unique IDs."""
|
||||
instance1 = SubMultipleInstance(root_instance, custom_param="first")
|
||||
instance2 = SubMultipleInstance(root_instance, custom_param="second")
|
||||
|
||||
assert instance1 is not instance2
|
||||
assert instance1.get_id() != instance2.get_id()
|
||||
assert instance1.get_id().startswith("mf-sub_multiple_instance-")
|
||||
assert instance2.get_id().startswith("mf-sub_multiple_instance-")
|
||||
|
||||
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
|
||||
"""Test that subclass with custom parameters works correctly."""
|
||||
instance = SubMultipleInstance(root_instance, custom_param="value")
|
||||
|
||||
assert instance.get_parent() == root_instance
|
||||
assert instance.get_session() == root_instance.get_session()
|
||||
assert instance.custom_param == "value"
|
||||
|
||||
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
|
||||
"""Test that cache works for subclasses."""
|
||||
instance1 = SubMultipleInstance(root_instance, custom_param="first")
|
||||
instance2 = SubMultipleInstance(root_instance, custom_param="second", _id=instance1.get_id())
|
||||
|
||||
assert instance1 is instance2
|
||||
|
||||
def test_i_cannot_retrieve_subclass_instance_when_type_differs(self, root_instance):
|
||||
"""Test that cache works for subclasses with custom _id."""
|
||||
# Need to pass _id explicitly to enable caching
|
||||
instance1 = SubMultipleInstance(root_instance)
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
MultipleInstance(parent=root_instance, _id=instance1.get_id())
|
||||
|
||||
def test_i_can_get_correct_prefix_for_multiple_subclass(self, root_instance):
|
||||
"""Test that subclass has correct auto-generated prefix."""
|
||||
prefix = SubMultipleInstance(root_instance).get_prefix()
|
||||
|
||||
assert prefix == "mf-sub_multiple_instance"
|
||||
|
||||
|
||||
class TestInstancesManager:
|
||||
|
||||
def test_i_can_register_an_instance_manually(self, session, root_instance):
|
||||
"""Test that an instance can be manually registered."""
|
||||
instance = BaseInstance(parent=root_instance, session=session, _id="manual_id", auto_register=False)
|
||||
|
||||
InstancesManager.register(session, instance)
|
||||
|
||||
session_id = InstancesManager.get_session_id(session)
|
||||
key = (session_id, "manual_id")
|
||||
|
||||
assert key in InstancesManager.instances
|
||||
assert InstancesManager.instances[key] is instance
|
||||
|
||||
def test_i_can_get_existing_instance_by_id(self, session, root_instance):
|
||||
"""Test that an existing instance can be retrieved by ID."""
|
||||
instance = BaseInstance(parent=root_instance, session=session, _id="get_id")
|
||||
|
||||
retrieved = InstancesManager.get(session, "get_id")
|
||||
|
||||
assert retrieved is instance
|
||||
|
||||
def test_i_cannot_get_nonexistent_instance_without_type(self, session):
|
||||
"""Test that getting a non-existent instance without type raises KeyError."""
|
||||
with pytest.raises(KeyError):
|
||||
InstancesManager.get(session, "nonexistent_id")
|
||||
|
||||
def test_i_can_get_session_id_from_valid_session(self, session):
|
||||
"""Test that session ID is correctly extracted from a valid session."""
|
||||
session_id = InstancesManager.get_session_id(session)
|
||||
|
||||
assert session_id == "test-user-123"
|
||||
|
||||
def test_i_can_handle_none_session(self):
|
||||
"""Test that None session returns a special identifier."""
|
||||
session_id = InstancesManager.get_session_id(None)
|
||||
|
||||
assert session_id == "** NOT LOGGED IN **"
|
||||
|
||||
def test_i_can_handle_invalid_session(self):
|
||||
"""Test that invalid sessions return appropriate identifiers."""
|
||||
# Session is None
|
||||
session_id = InstancesManager.get_session_id(None)
|
||||
assert session_id == "** NOT LOGGED IN **"
|
||||
|
||||
# Session without user_info
|
||||
session_no_user = {}
|
||||
session_id = InstancesManager.get_session_id(session_no_user)
|
||||
assert session_id == "** UNKNOWN USER **"
|
||||
|
||||
# Session with user_info but no id
|
||||
session_no_id = {"user_info": {}}
|
||||
session_id = InstancesManager.get_session_id(session_no_id)
|
||||
assert session_id == "** INVALID SESSION **"
|
||||
|
||||
def test_i_can_reset_all_instances(self, session, root_instance):
|
||||
"""Test that reset() clears all instances."""
|
||||
BaseInstance(parent=root_instance, session=session, _id="id1")
|
||||
BaseInstance(parent=root_instance, session=session, _id="id2")
|
||||
|
||||
assert len(InstancesManager.instances) > 0
|
||||
|
||||
InstancesManager.reset()
|
||||
|
||||
assert len(InstancesManager.instances) == 0
|
||||
|
||||
|
||||
class TestRootInstance:
|
||||
|
||||
def test_i_can_create_root_instance_with_positional_args(self):
|
||||
"""Test that RootInstance can be created with positional arguments."""
|
||||
root = SingleInstance(None, special_session, Ids.Root)
|
||||
|
||||
assert root is not None
|
||||
assert root.get_id() == Ids.Root
|
||||
assert root.get_session() == special_session
|
||||
assert root.get_parent() is None
|
||||
|
||||
def test_i_can_access_root_instance(self):
|
||||
"""Test that RootInstance is created and accessible."""
|
||||
assert RootInstance is not None
|
||||
assert RootInstance.get_id() == Ids.Root
|
||||
assert RootInstance.get_session() == special_session
|
||||
@@ -0,0 +1,105 @@
|
||||
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")]
|
||||
@@ -0,0 +1,648 @@
|
||||
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]
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Create mouse bindings
|
||||
*/
|
||||
(function () {
|
||||
/**
|
||||
* Global registry to store mouse shortcuts for multiple elements
|
||||
*/
|
||||
const MouseRegistry = {
|
||||
elements: new Map(), // elementId -> { tree, element }
|
||||
listenerAttached: false,
|
||||
snapshotHistory: [],
|
||||
pendingTimeout: null,
|
||||
pendingMatches: [], // Array of matches waiting for timeout
|
||||
sequenceTimeout: 500, // 500ms timeout for sequences
|
||||
clickHandler: null,
|
||||
contextmenuHandler: null
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize mouse action names
|
||||
* @param {string} action - The action to normalize
|
||||
* @returns {string} - Normalized action name
|
||||
*/
|
||||
function normalizeAction(action) {
|
||||
const normalized = action.toLowerCase().trim();
|
||||
|
||||
// Handle aliases
|
||||
const aliasMap = {
|
||||
'rclick': 'right_click'
|
||||
};
|
||||
|
||||
return aliasMap[normalized] || normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a unique string key from a Set of actions for Map indexing
|
||||
* @param {Set} actionSet - Set of normalized actions
|
||||
* @returns {string} - Sorted string representation
|
||||
*/
|
||||
function setToKey(actionSet) {
|
||||
return Array.from(actionSet).sort().join('+');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single element (can be a simple click or click with modifiers)
|
||||
* @param {string} element - The element string (e.g., "click" or "ctrl+click")
|
||||
* @returns {Set} - Set of normalized actions
|
||||
*/
|
||||
function parseElement(element) {
|
||||
if (element.includes('+')) {
|
||||
// Click with modifiers
|
||||
return new Set(element.split('+').map(a => normalizeAction(a)));
|
||||
}
|
||||
// Simple click
|
||||
return new Set([normalizeAction(element)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a combination string into sequence elements
|
||||
* @param {string} combination - The combination string (e.g., "click right_click")
|
||||
* @returns {Array} - Array of Sets representing the sequence
|
||||
*/
|
||||
function parseCombination(combination) {
|
||||
// Check if it's a sequence (contains space)
|
||||
if (combination.includes(' ')) {
|
||||
return combination.split(' ').map(el => parseElement(el.trim()));
|
||||
}
|
||||
|
||||
// Single element (can be a click or click with modifiers)
|
||||
return [parseElement(combination)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tree node
|
||||
* @returns {Object} - New tree node
|
||||
*/
|
||||
function createTreeNode() {
|
||||
return {
|
||||
config: null,
|
||||
combinationStr: null,
|
||||
children: new Map()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a tree from combinations
|
||||
* @param {Object} combinations - Map of combination strings to HTMX config objects
|
||||
* @returns {Object} - Root tree node
|
||||
*/
|
||||
function buildTree(combinations) {
|
||||
const root = createTreeNode();
|
||||
|
||||
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||
const sequence = parseCombination(combinationStr);
|
||||
console.log("Parsing mouse combination", combinationStr, "=>", sequence);
|
||||
let currentNode = root;
|
||||
|
||||
for (const actionSet of sequence) {
|
||||
const key = setToKey(actionSet);
|
||||
|
||||
if (!currentNode.children.has(key)) {
|
||||
currentNode.children.set(key, createTreeNode());
|
||||
}
|
||||
|
||||
currentNode = currentNode.children.get(key);
|
||||
}
|
||||
|
||||
// Mark as end of sequence and store config
|
||||
currentNode.config = config;
|
||||
currentNode.combinationStr = combinationStr;
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse the tree with the current snapshot history
|
||||
* @param {Object} treeRoot - Root of the tree
|
||||
* @param {Array} snapshotHistory - Array of Sets representing mouse actions
|
||||
* @returns {Object|null} - Current node or null if no match
|
||||
*/
|
||||
function traverseTree(treeRoot, snapshotHistory) {
|
||||
let currentNode = treeRoot;
|
||||
|
||||
for (const snapshot of snapshotHistory) {
|
||||
const key = setToKey(snapshot);
|
||||
|
||||
if (!currentNode.children.has(key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
currentNode = currentNode.children.get(key);
|
||||
}
|
||||
|
||||
return currentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're inside an input element where clicking should work normally
|
||||
* @returns {boolean} - True if inside an input-like element
|
||||
*/
|
||||
function isInInputContext() {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
|
||||
// Check for input/textarea
|
||||
if (tagName === 'input' || tagName === 'textarea') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for contenteditable
|
||||
if (activeElement.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the element that was actually clicked (from registered elements)
|
||||
* @param {Element} target - The clicked element
|
||||
* @returns {string|null} - Element ID if found, null otherwise
|
||||
*/
|
||||
function findRegisteredElement(target) {
|
||||
// Check if target itself is registered
|
||||
if (target.id && MouseRegistry.elements.has(target.id)) {
|
||||
return target.id;
|
||||
}
|
||||
|
||||
// Check if any parent is registered
|
||||
let current = target.parentElement;
|
||||
while (current) {
|
||||
if (current.id && MouseRegistry.elements.has(current.id)) {
|
||||
return current.id;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot from mouse event
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||
* @returns {Set} - Set of actions representing this click
|
||||
*/
|
||||
function createSnapshot(event, baseAction) {
|
||||
const actions = new Set([baseAction]);
|
||||
|
||||
// Add modifiers if present
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
actions.add('ctrl');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
actions.add('shift');
|
||||
}
|
||||
if (event.altKey) {
|
||||
actions.add('alt');
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an action for a matched combination
|
||||
* @param {string} elementId - ID of the element
|
||||
* @param {Object} config - HTMX configuration object
|
||||
* @param {string} combinationStr - The matched combination string
|
||||
* @param {boolean} isInside - Whether the click was inside the element
|
||||
*/
|
||||
function triggerAction(elementId, config, combinationStr, isInside) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) return;
|
||||
|
||||
const hasFocus = document.activeElement === element;
|
||||
|
||||
// Extract HTTP method and URL from hx-* attributes
|
||||
let method = 'POST'; // default
|
||||
let url = null;
|
||||
|
||||
const methodMap = {
|
||||
'hx-post': 'POST',
|
||||
'hx-get': 'GET',
|
||||
'hx-put': 'PUT',
|
||||
'hx-delete': 'DELETE',
|
||||
'hx-patch': 'PATCH'
|
||||
};
|
||||
|
||||
for (const [attr, httpMethod] of Object.entries(methodMap)) {
|
||||
if (config[attr]) {
|
||||
method = httpMethod;
|
||||
url = config[attr];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
console.error('No HTTP method attribute found in config:', config);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build htmx.ajax options
|
||||
const htmxOptions = {};
|
||||
|
||||
// Map hx-target to target
|
||||
if (config['hx-target']) {
|
||||
htmxOptions.target = config['hx-target'];
|
||||
}
|
||||
|
||||
// Map hx-swap to swap
|
||||
if (config['hx-swap']) {
|
||||
htmxOptions.swap = config['hx-swap'];
|
||||
}
|
||||
|
||||
// Map hx-vals to values and add combination, has_focus, and is_inside
|
||||
const values = {};
|
||||
if (config['hx-vals']) {
|
||||
Object.assign(values, config['hx-vals']);
|
||||
}
|
||||
values.combination = combinationStr;
|
||||
values.has_focus = hasFocus;
|
||||
values.is_inside = isInside;
|
||||
htmxOptions.values = values;
|
||||
|
||||
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
|
||||
// Remove 'hx-' prefix and convert to camelCase
|
||||
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
htmxOptions[optionKey] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Make AJAX call with htmx
|
||||
htmx.ajax(method, url, htmxOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse events and trigger matching combinations
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
* @param {string} baseAction - The base action ('click' or 'right_click')
|
||||
*/
|
||||
function handleMouseEvent(event, baseAction) {
|
||||
// Different behavior for click vs right_click
|
||||
if (baseAction === 'click') {
|
||||
// Click: trigger for ALL registered elements (useful for closing modals/popups)
|
||||
handleGlobalClick(event);
|
||||
} else if (baseAction === 'right_click') {
|
||||
// Right-click: trigger ONLY if clicked on a registered element
|
||||
handleElementRightClick(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle global click events (triggers for all registered elements)
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
*/
|
||||
function handleGlobalClick(event) {
|
||||
console.debug("Global click detected");
|
||||
|
||||
// Create a snapshot of current mouse action with modifiers
|
||||
const snapshot = createSnapshot(event, 'click');
|
||||
|
||||
// Add snapshot to history
|
||||
MouseRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
|
||||
// Collect match information for ALL registered elements
|
||||
const currentMatches = [];
|
||||
let anyHasLongerSequence = false;
|
||||
let foundAnyMatch = false;
|
||||
|
||||
for (const [elementId, data] of MouseRegistry.elements) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) continue;
|
||||
|
||||
// Check if click was inside this element
|
||||
const isInside = element.contains(event.target);
|
||||
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this tree
|
||||
continue;
|
||||
}
|
||||
|
||||
// We found at least a partial match
|
||||
foundAnyMatch = true;
|
||||
|
||||
// Check if we have a match (node has config)
|
||||
const hasMatch = currentNode.config !== null;
|
||||
|
||||
// Check if there are longer sequences possible (node has children)
|
||||
const hasLongerSequences = currentNode.children.size > 0;
|
||||
|
||||
if (hasLongerSequences) {
|
||||
anyHasLongerSequence = true;
|
||||
}
|
||||
|
||||
// Collect matches
|
||||
if (hasMatch) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: isInside
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default if we found any match and not in input context
|
||||
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Decision logic based on matches and longer sequences
|
||||
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||
// We have matches and NO longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
|
||||
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||
// We have matches but longer sequences are possible
|
||||
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||
|
||||
MouseRegistry.pendingMatches = currentMatches;
|
||||
|
||||
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of MouseRegistry.pendingMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
MouseRegistry.pendingMatches = [];
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}, MouseRegistry.sequenceTimeout);
|
||||
|
||||
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||
// No matches yet but longer sequences are possible
|
||||
// Just wait, don't trigger anything
|
||||
|
||||
} else {
|
||||
// No matches and no longer sequences possible
|
||||
// This is an invalid sequence - clear history
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// If we found no match at all, clear the history
|
||||
if (!foundAnyMatch) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle right-click events (triggers only for clicked element)
|
||||
* @param {MouseEvent} event - The mouse event
|
||||
*/
|
||||
function handleElementRightClick(event) {
|
||||
// Find which registered element was clicked
|
||||
const elementId = findRegisteredElement(event.target);
|
||||
|
||||
if (!elementId) {
|
||||
// Right-click wasn't on a registered element - don't prevent default
|
||||
// This allows browser context menu to appear
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug("Right-click on registered element", elementId);
|
||||
|
||||
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
|
||||
const clickedInside = true;
|
||||
|
||||
// Create a snapshot of current mouse action with modifiers
|
||||
const snapshot = createSnapshot(event, 'right_click');
|
||||
|
||||
// Add snapshot to history
|
||||
MouseRegistry.snapshotHistory.push(snapshot);
|
||||
|
||||
// Cancel any pending timeout
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
|
||||
// Collect match information for this element
|
||||
const currentMatches = [];
|
||||
let anyHasLongerSequence = false;
|
||||
let foundAnyMatch = false;
|
||||
|
||||
const data = MouseRegistry.elements.get(elementId);
|
||||
if (!data) return;
|
||||
|
||||
const treeRoot = data.tree;
|
||||
|
||||
// Traverse the tree with current snapshot history
|
||||
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
|
||||
|
||||
if (!currentNode) {
|
||||
// No match in this tree
|
||||
console.debug("No match in tree for right-click");
|
||||
// Clear history for invalid sequences
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// We found at least a partial match
|
||||
foundAnyMatch = true;
|
||||
|
||||
// Check if we have a match (node has config)
|
||||
const hasMatch = currentNode.config !== null;
|
||||
|
||||
// Check if there are longer sequences possible (node has children)
|
||||
const hasLongerSequences = currentNode.children.size > 0;
|
||||
|
||||
if (hasLongerSequences) {
|
||||
anyHasLongerSequence = true;
|
||||
}
|
||||
|
||||
// Collect matches
|
||||
if (hasMatch) {
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: true // Right-click only triggers when clicking on element
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent default if we found any match and not in input context
|
||||
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
// Decision logic based on matches and longer sequences
|
||||
if (currentMatches.length > 0 && !anyHasLongerSequence) {
|
||||
// We have matches and NO longer sequences possible
|
||||
// Trigger ALL matches immediately
|
||||
for (const match of currentMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear history after triggering
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
|
||||
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
|
||||
// We have matches but longer sequences are possible
|
||||
// Wait for timeout - ALL current matches will be triggered if timeout expires
|
||||
|
||||
MouseRegistry.pendingMatches = currentMatches;
|
||||
|
||||
MouseRegistry.pendingTimeout = setTimeout(() => {
|
||||
// Timeout expired, trigger ALL pending matches
|
||||
for (const match of MouseRegistry.pendingMatches) {
|
||||
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
|
||||
}
|
||||
|
||||
// Clear state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
MouseRegistry.pendingMatches = [];
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}, MouseRegistry.sequenceTimeout);
|
||||
|
||||
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
|
||||
// No matches yet but longer sequences are possible
|
||||
// Just wait, don't trigger anything
|
||||
|
||||
} else {
|
||||
// No matches and no longer sequences possible
|
||||
// This is an invalid sequence - clear history
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// If we found no match at all, clear the history
|
||||
if (!foundAnyMatch) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
|
||||
// Also clear history if it gets too long (prevent memory issues)
|
||||
if (MouseRegistry.snapshotHistory.length > 10) {
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the global mouse event listeners if not already attached
|
||||
*/
|
||||
function attachGlobalListener() {
|
||||
if (!MouseRegistry.listenerAttached) {
|
||||
// Store handler references for proper removal
|
||||
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
|
||||
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
|
||||
|
||||
document.addEventListener('click', MouseRegistry.clickHandler);
|
||||
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
MouseRegistry.listenerAttached = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the global mouse event listeners
|
||||
*/
|
||||
function detachGlobalListener() {
|
||||
if (MouseRegistry.listenerAttached) {
|
||||
document.removeEventListener('click', MouseRegistry.clickHandler);
|
||||
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
MouseRegistry.listenerAttached = false;
|
||||
|
||||
// Clean up handler references
|
||||
MouseRegistry.clickHandler = null;
|
||||
MouseRegistry.contextmenuHandler = null;
|
||||
|
||||
// Clean up all state
|
||||
MouseRegistry.snapshotHistory = [];
|
||||
if (MouseRegistry.pendingTimeout) {
|
||||
clearTimeout(MouseRegistry.pendingTimeout);
|
||||
MouseRegistry.pendingTimeout = null;
|
||||
}
|
||||
MouseRegistry.pendingMatches = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add mouse support to an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
* @param {string} combinationsJson - JSON string of combinations mapping
|
||||
*/
|
||||
window.add_mouse_support = function (elementId, combinationsJson) {
|
||||
// Parse the combinations JSON
|
||||
const combinations = JSON.parse(combinationsJson);
|
||||
|
||||
// Build tree for this element
|
||||
const tree = buildTree(combinations);
|
||||
|
||||
// Get element reference
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) {
|
||||
console.error("Element with ID", elementId, "not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to registry
|
||||
MouseRegistry.elements.set(elementId, {
|
||||
tree: tree,
|
||||
element: element
|
||||
});
|
||||
|
||||
// Attach global listener if not already attached
|
||||
attachGlobalListener();
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove mouse support from an element
|
||||
* @param {string} elementId - The ID of the element
|
||||
*/
|
||||
window.remove_mouse_support = function (elementId) {
|
||||
// Remove from registry
|
||||
if (!MouseRegistry.elements.has(elementId)) {
|
||||
console.warn("Element with ID", elementId, "not found in mouse registry!");
|
||||
return;
|
||||
}
|
||||
|
||||
MouseRegistry.elements.delete(elementId);
|
||||
|
||||
// If no more elements, detach global listeners
|
||||
if (MouseRegistry.elements.size === 0) {
|
||||
detachGlobalListener();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,309 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,356 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mouse Support Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 20px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.test-container {
|
||||
border: 2px solid #333;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.test-element {
|
||||
background-color: #f0f0f0;
|
||||
border: 2px solid #999;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin: 10px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.test-element:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.test-element:focus {
|
||||
background-color: #e3f2fd;
|
||||
border-color: #2196F3;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.log-container {
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin: 5px 0;
|
||||
padding: 5px;
|
||||
border-left: 3px solid #4CAF50;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.log-entry.focus {
|
||||
border-left-color: #2196F3;
|
||||
}
|
||||
|
||||
.log-entry.no-focus {
|
||||
border-left-color: #FF9800;
|
||||
}
|
||||
|
||||
.actions-list {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.actions-list h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.actions-list ul {
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.actions-list code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
background-color: #f44336;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
background-color: #d32f2f;
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
background-color: #FF5722;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.remove-button:hover {
|
||||
background-color: #E64A19;
|
||||
}
|
||||
|
||||
.remove-button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.note {
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196F3;
|
||||
padding: 10px 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Mouse Support Test Page</h1>
|
||||
|
||||
<div class="actions-list">
|
||||
<h3>🖱️ Configured Mouse Actions</h3>
|
||||
<p><strong>Element 1 - All Actions:</strong></p>
|
||||
<ul>
|
||||
<li><code>click</code> - Simple left click</li>
|
||||
<li><code>right_click</code> - Right click (context menu blocked)</li>
|
||||
<li><code>ctrl+click</code> - Ctrl/Cmd + Click</li>
|
||||
<li><code>shift+click</code> - Shift + Click</li>
|
||||
<li><code>ctrl+shift+click</code> - Ctrl + Shift + Click</li>
|
||||
<li><code>click right_click</code> - Click then right-click within 500ms</li>
|
||||
<li><code>click click</code> - Click twice in sequence</li>
|
||||
</ul>
|
||||
<p><strong>Element 2 - Using rclick alias:</strong></p>
|
||||
<ul>
|
||||
<li><code>click</code> - Simple click</li>
|
||||
<li><code>rclick</code> - Right click (using rclick alias)</li>
|
||||
<li><code>click rclick</code> - Click then right-click sequence (using alias)</li>
|
||||
</ul>
|
||||
<p><strong>Note:</strong> <code>rclick</code> is an alias for <code>right_click</code> and works identically.</p>
|
||||
<p><strong>Tip:</strong> Try different click combinations! Right-click menu will be blocked on test elements.</p>
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Click Behavior:</strong> The <code>click</code> action is detected GLOBALLY (anywhere on the page).
|
||||
Try clicking outside the test elements - the click action will still trigger! The <code>is_inside</code>
|
||||
parameter tells you if the click was inside or outside the element (perfect for "close popup if clicked outside" logic).
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Right-Click Behavior:</strong> The <code>right_click</code> action is detected ONLY when clicking ON the element.
|
||||
Try right-clicking outside the test elements - the browser's context menu will appear normally.
|
||||
</div>
|
||||
|
||||
<div class="note">
|
||||
<strong>Mac Users:</strong> Use Cmd (⌘) instead of Ctrl. The library handles cross-platform compatibility automatically.
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Test Element 1 (All Actions)</h2>
|
||||
<div id="test-element-1" class="test-element" tabindex="0">
|
||||
Try different mouse actions here!<br>
|
||||
Click, Right-click, Ctrl+Click, Shift+Click, sequences...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Test Element 2 (Using rclick alias)</h2>
|
||||
<div id="test-element-2" class="test-element" tabindex="0">
|
||||
This element uses "rclick" alias for right-click<br>
|
||||
Also has a "click rclick" sequence
|
||||
</div>
|
||||
<button class="remove-button" onclick="removeElement2()">
|
||||
Remove Element 2 Mouse Support
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Test Input (normal clicking should work here)</h2>
|
||||
<input type="text" placeholder="Try clicking, right-clicking here - should work normally"
|
||||
style="width: 100%; padding: 10px; font-size: 14px;">
|
||||
</div>
|
||||
|
||||
<div class="test-container" style="background-color: #f9f9f9;">
|
||||
<h2>🎯 Click Outside Test Area</h2>
|
||||
<p>Click anywhere in this gray area (outside the test elements above) to see that <code>click</code> is detected globally!</p>
|
||||
<p style="margin-top: 20px; padding: 30px; background-color: white; border: 2px dashed #999; border-radius: 5px; text-align: center;">
|
||||
This is just empty space - but clicking here will still trigger the registered <code>click</code> actions!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="test-container">
|
||||
<h2>Event Log</h2>
|
||||
<button class="clear-button" onclick="clearLog()">Clear Log</button>
|
||||
<div id="log" class="log-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Include htmx -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Mock htmx.ajax for testing -->
|
||||
<script>
|
||||
// Store original htmx.ajax if it exists
|
||||
const originalHtmxAjax = window.htmx && window.htmx.ajax;
|
||||
|
||||
// Override htmx.ajax for testing purposes
|
||||
if (window.htmx) {
|
||||
window.htmx.ajax = function(method, url, config) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const hasFocus = config.values.has_focus;
|
||||
const isInside = config.values.is_inside;
|
||||
const combination = config.values.combination;
|
||||
|
||||
// Build details string with all config options
|
||||
const details = [
|
||||
`Combination: "${combination}"`,
|
||||
`Element has focus: ${hasFocus}`,
|
||||
`Click inside element: ${isInside}`
|
||||
];
|
||||
|
||||
if (config.target) {
|
||||
details.push(`Target: ${config.target}`);
|
||||
}
|
||||
if (config.swap) {
|
||||
details.push(`Swap: ${config.swap}`);
|
||||
}
|
||||
if (config.values) {
|
||||
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus' && k !== 'is_inside');
|
||||
if (extraVals.length > 0) {
|
||||
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
|
||||
}
|
||||
}
|
||||
|
||||
logEvent(
|
||||
`[${timestamp}] ${method} ${url}`,
|
||||
...details,
|
||||
hasFocus
|
||||
);
|
||||
|
||||
// Uncomment below to use real htmx.ajax if you have a backend
|
||||
// if (originalHtmxAjax) {
|
||||
// originalHtmxAjax.call(this, method, url, config);
|
||||
// }
|
||||
};
|
||||
}
|
||||
|
||||
function logEvent(title, ...details) {
|
||||
const log = document.getElementById('log');
|
||||
const hasFocus = details[details.length - 1];
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
|
||||
entry.innerHTML = `
|
||||
<strong>${title}</strong><br>
|
||||
${details.slice(0, -1).join('<br>')}
|
||||
`;
|
||||
|
||||
log.insertBefore(entry, log.firstChild);
|
||||
}
|
||||
|
||||
function clearLog() {
|
||||
document.getElementById('log').innerHTML = '';
|
||||
}
|
||||
|
||||
function removeElement2() {
|
||||
remove_mouse_support('test-element-2');
|
||||
logEvent('Element 2 mouse support removed',
|
||||
'Click and right-click no longer trigger for Element 2',
|
||||
'Element 1 still active', false);
|
||||
// Disable the button
|
||||
event.target.disabled = true;
|
||||
event.target.textContent = 'Mouse Support Removed';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Include mouse support script -->
|
||||
<script src="mouse_support.js"></script>
|
||||
|
||||
<!-- Initialize mouse support -->
|
||||
<script>
|
||||
// Element 1 - Full configuration
|
||||
const combinations1 = {
|
||||
"click": {
|
||||
"hx-post": "/test/click"
|
||||
},
|
||||
"right_click": {
|
||||
"hx-post": "/test/right-click"
|
||||
},
|
||||
"ctrl+click": {
|
||||
"hx-post": "/test/ctrl-click",
|
||||
"hx-swap": "innerHTML"
|
||||
},
|
||||
"shift+click": {
|
||||
"hx-post": "/test/shift-click",
|
||||
"hx-target": "#result"
|
||||
},
|
||||
"ctrl+shift+click": {
|
||||
"hx-post": "/test/ctrl-shift-click",
|
||||
"hx-vals": {"modifier": "both"}
|
||||
},
|
||||
"click right_click": {
|
||||
"hx-post": "/test/click-then-right-click",
|
||||
"hx-vals": {"type": "sequence"}
|
||||
},
|
||||
"click click": {
|
||||
"hx-post": "/test/double-click-sequence"
|
||||
}
|
||||
};
|
||||
|
||||
add_mouse_support('test-element-1', JSON.stringify(combinations1));
|
||||
|
||||
// Element 2 - Using rclick alias
|
||||
const combinations2 = {
|
||||
"click": {
|
||||
"hx-post": "/test/element2-click"
|
||||
},
|
||||
"rclick": { // Using rclick alias instead of right_click
|
||||
"hx-post": "/test/element2-rclick"
|
||||
},
|
||||
"click rclick": { // Sequence using rclick alias
|
||||
"hx-post": "/test/element2-click-rclick-sequence"
|
||||
}
|
||||
};
|
||||
|
||||
add_mouse_support('test-element-2', JSON.stringify(combinations2));
|
||||
|
||||
// Log initial state
|
||||
logEvent('Mouse support initialized',
|
||||
'Element 1: All mouse actions configured',
|
||||
'Element 2: Using "rclick" alias (click, rclick, and click rclick sequence)',
|
||||
'Smart timeout: 500ms for sequences', false);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
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,7 +3,7 @@ from fastcore.basics import NotStr
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
|
||||
ErrorComparisonOutput, AttributeForbidden, AnyValue
|
||||
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren
|
||||
from myfasthtml.test.testclient import MyFT
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ from myfasthtml.test.testclient import MyFT
|
||||
([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))),
|
||||
@@ -54,6 +56,7 @@ def test_i_can_match(actual, expected):
|
||||
(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"),
|
||||
@@ -203,6 +206,7 @@ def test_i_can_output_error_child_element():
|
||||
')',
|
||||
]
|
||||
|
||||
|
||||
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")
|
||||
@@ -217,6 +221,7 @@ def test_i_can_output_error_child_element_text():
|
||||
')',
|
||||
]
|
||||
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user