7 Commits

47 changed files with 8239 additions and 765 deletions

View File

@@ -201,99 +201,489 @@ class TestControlRender:
### UTR-11: Required Reading for Control Render Tests
**Before writing ANY render tests for Controls, you MUST:**
---
1. **Read the matcher documentation**: `docs/testing_rendered_components.md`
2. **Understand the key concepts**:
- How `matches()` and `find()` work
- When to use predicates (Contains, StartsWith, AnyValue, etc.)
- How to test only what matters (not every detail)
- How to read error messages with `^^^` markers
3. **Apply the best practices**:
- Test only important structural elements and attributes
- Use predicates for dynamic/generated values
- Don't over-specify tests with irrelevant details
- Structure tests in layers (overall structure, then details)
#### **UTR-11.0: Read the matcher documentation (MANDATORY PREREQUISITE)**
**Principle:** Before writing any render tests, you MUST read and understand the complete matcher documentation.
**Mandatory reading:** `docs/testing_rendered_components.md`
**What you must master:**
- **`matches(actual, expected)`** - How to validate that an element matches your expectations
- **`find(ft, expected)`** - How to search for elements within an HTML tree
- **Predicates** - How to test patterns instead of exact values:
- `Contains()`, `StartsWith()`, `DoesNotContain()`, `AnyValue()` for attributes
- `Empty()`, `NoChildren()`, `AttributeForbidden()` for children
- **Error messages** - How to read `^^^` markers to understand differences
- **Key principle** - Test only what matters, ignore the rest
**Without this reading, you cannot write correct render tests.**
---
### **TEST FILE STRUCTURE**
---
#### **UTR-11.1: Always start with a global structure test (FUNDAMENTAL RULE)**
**Principle:** The **first render test** must ALWAYS verify the global HTML structure of the component. This is the test that helps readers understand the general architecture.
**Why:**
- Gives immediate overview of the structure
- Facilitates understanding for new contributors
- Quickly detects major structural changes
- Serves as living documentation of HTML architecture
**Test format:**
```python
def test_i_can_render_component_with_no_data(self, component):
"""Test that Component renders with correct global structure."""
html = component.render()
expected = Div(
Div(id=f"{component.get_id()}-controller"), # controller
Div(id=f"{component.get_id()}-header"), # header
Div(id=f"{component.get_id()}-content"), # content
id=component.get_id(),
)
assert matches(html, expected)
```
**Notes:**
- Simple test with only IDs of main sections
- Inline comments to identify each section
- No detailed verification of attributes (classes, content, etc.)
- This test must be the first in the `TestComponentRender` class
**Test order:**
1. **First test:** Global structure (UTR-11.1)
2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.10)
---
#### **UTR-11.2: Break down complex tests into explicit steps**
**Principle:** When a test verifies multiple levels of HTML nesting, break it down into numbered steps with explicit comments.
**Why:**
- Facilitates debugging (you know exactly which step fails)
- Improves test readability
- Allows validating structure level by level
**Example:**
```python
def test_content_wrapper_when_tab_active(self, tabs_manager):
"""Test that content wrapper shows active tab content."""
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
wrapper = tabs_manager._mk_tab_content_wrapper()
# Step 1: Validate wrapper global structure
expected = Div(
Div(), # tab content, tested in step 2
id=f"{tabs_manager.get_id()}-content-wrapper",
cls=Contains("mf-tab-content-wrapper"),
)
assert matches(wrapper, expected)
# Step 2: Extract and validate specific content
tab_content = find_one(wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
expected = Div(
Div("My Content"), # <= actual content
cls=Contains("mf-tab-content"),
)
assert matches(tab_content, expected)
```
**Pattern:**
- Step 1: Global structure with empty `Div()` + comment for children tested after
- Step 2+: Extraction with `find_one()` + detailed validation
---
#### **UTR-11.3: Three-step pattern for simple tests**
**Principle:** For tests not requiring multi-level decomposition, use the standard three-step pattern.
**The three steps:**
1. **Extract the element to test** with `find_one()` or `find()` from the global render
2. **Define the expected structure** with `expected = ...`
3. **Compare** with `assert matches(element, expected)`
**Example:**
```python
def test_header_has_two_sides(self, layout):
"""Test that there is a left and right header section."""
# Step 1: Extract the element to test
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
# Step 2: Define the expected structure
expected = Header(
Div(id=f"{layout._id}_hl"),
Div(id=f"{layout._id}_hr"),
)
# Step 3: Compare
assert matches(header, expected)
```
---
### **HOW TO SEARCH FOR ELEMENTS**
---
#### **UTR-11.4: Prefer searching by ID**
**Principle:** Always search for an element by its `id` when it has one, rather than by class or other attribute.
**Why:** More robust, faster, and targeted (an ID is unique).
**Example:**
```python
# ✅ GOOD - search by ID
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
# ❌ AVOID - search by class when an ID exists
drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer")))
```
---
#### **UTR-11.5: Use `find_one()` vs `find()` based on context**
**Principle:**
- `find_one()`: When you search for a unique element and want to test its complete structure
- `find()`: When you search for multiple elements or want to count/verify their presence
**Examples:**
```python
# ✅ GOOD - find_one for unique structure
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
expected = Header(...)
assert matches(header, expected)
# ✅ GOOD - find for counting
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
```
---
### **HOW TO SPECIFY EXPECTED STRUCTURE**
---
#### **UTR-11.6: Always use `Contains()` for `cls` and `style` attributes**
**Principle:**
- For `cls`: CSS classes can be in any order. Test only important classes with `Contains()`.
- For `style`: CSS properties can be in any order. Test only important properties with `Contains()`.
**Why:** Avoids false negatives due to class/property order or spacing.
**Examples:**
```python
# ✅ GOOD - Contains for cls (one or more classes)
expected = Div(cls=Contains("mf-layout-drawer"))
expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"))
# ✅ GOOD - Contains for style
expected = Div(style=Contains("width: 250px"))
# ❌ AVOID - exact class test
expected = Div(cls="mf-layout-drawer mf-layout-left-drawer")
# ❌ AVOID - exact complete style test
expected = Div(style="width: 250px; overflow: hidden; display: flex;")
```
---
#### **UTR-11.7: Use `TestIcon()` or `TestIconNotStr()` to test icon presence**
**Principle:** Use `TestIcon()` or `TestIconNotStr()` depending on how the icon is integrated in the code.
**Difference between the two:**
- **`TestIcon("icon_name")`**: Searches for the pattern `<div><NotStr .../></div>` (icon wrapped in a Div)
- **`TestIconNotStr("icon_name")`**: Searches only for `<NotStr .../>` (icon alone, without wrapper)
**How to choose:**
1. **Read the source code** to see how the icon is rendered
2. If `mk.icon()` or equivalent wraps the icon in a Div → use `TestIcon()`
3. If the icon is directly included without wrapper → use `TestIconNotStr()`
**The `name` parameter:**
- **Exact name**: Use the exact import name (e.g., `TestIcon("panel_right_expand20_regular")`) to validate a specific icon
- **`name=""`** (empty string): Validates **any icon**
**Examples:**
```python
# Example 1: Wrapped icon (typically with mk.icon())
# Source code: mk.icon(panel_right_expand20_regular, size=20)
# Rendered: <div><NotStr .../></div>
expected = Header(
Div(
TestIcon("panel_right_expand20_regular"), # ✅ With wrapper
cls=Contains("flex", "gap-1")
)
)
# Example 2: Direct icon (used without helper)
# Source code: Span(dismiss_circle16_regular, cls="icon")
# Rendered: <span><NotStr .../></span>
expected = Span(
TestIconNotStr("dismiss_circle16_regular"), # ✅ Without wrapper
cls=Contains("icon")
)
# Example 3: Verify any wrapped icon
expected = Div(
TestIcon(""), # Accepts any wrapped icon
cls=Contains("icon-wrapper")
)
```
**Debugging tip:**
If your test fails with `TestIcon()`, try `TestIconNotStr()` and vice-versa. The error message will show you the actual structure.
---
#### **UTR-11.8: Use `TestScript()` to test JavaScript scripts**
**Principle:** Use `TestScript(code_fragment)` to verify JavaScript code presence. Test only the important fragment, not the complete script.
**Example:**
```python
# ✅ GOOD - TestScript with important fragment
script = find_one(layout.render(), Script())
expected = TestScript(f"initResizer('{layout._id}');")
assert matches(script, expected)
# ❌ AVOID - testing all script content
expected = Script("(function() { const id = '...'; initResizer(id); })()")
```
---
### **HOW TO DOCUMENT TESTS**
---
#### **UTR-11.9: Justify the choice of tested elements**
**Principle:** In the test documentation section (after the description docstring), explain **why each tested element or attribute was chosen**. What makes it important for the functionality?
**What matters:** Not the exact wording ("Why these elements matter" vs "Why this test matters"), but **the explanation of why what is tested is relevant**.
**Examples:**
```python
def test_empty_layout_is_rendered(self, layout):
"""Test that Layout renders with all main structural sections.
Why these elements matter:
- 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script)
- _id: Essential for layout identification and resizer initialization
- cls="mf-layout": Root CSS class for layout styling
"""
expected = Div(...)
assert matches(layout.render(), expected)
def test_left_drawer_is_rendered_when_open(self, layout):
"""Test that left drawer renders with correct classes when open.
Why these elements matter:
- _id: Required for targeting drawer in HTMX updates
- cls Contains "mf-layout-drawer": Base drawer class for styling
- cls Contains "mf-layout-left-drawer": Left-specific drawer positioning
- style Contains width: Drawer width must be applied for sizing
"""
layout._state.left_drawer_open = True
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
expected = Div(
_id=f"{layout._id}_ld",
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"),
style=Contains("width: 250px")
)
assert matches(drawer, expected)
```
**Key points:**
- Explain why the attribute/element is important (functionality, HTMX, styling, etc.)
- No need to follow rigid wording
- What matters is the **justification of the choice**, not the format
---
#### **UTR-11.10: Count tests with explicit messages**
**Principle:** When you count elements with `assert len()`, ALWAYS add an explicit message explaining why this number is expected.
**Example:**
```python
# ✅ GOOD - explanatory message
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
dividers = find(content, Div(cls="divider"))
assert len(dividers) >= 1, "Groups should be separated by dividers"
# ❌ AVOID - no message
assert len(resizers) == 1
```
---
### **OTHER IMPORTANT RULES**
---
**Mandatory render test rules:**
1. **Test naming**: Use descriptive names like `test_empty_layout_is_rendered()` not `test_layout_renders_with_all_sections()`
2. **Documentation format**: Every render test MUST have a docstring with:
- First line: Brief description of what is being tested
- Blank line
- "Why these elements matter:" or "Why this test matters:" section
- Justification section explaining why tested elements matter (see UTR-11.9)
- List of important elements/attributes being tested with explanations (in English)
3. **No inline comments**: Do NOT add comments on each line of the expected structure
4. **Icon testing**: Use `Div(NotStr(name="icon_name"))` to test SVG icons
- Use the exact icon name from the import (e.g., `name="panel_right_expand20_regular"`)
- Use `name=""` (empty string) if the import is not explicit
- NEVER use `name="svg"` - it will cause test failures
5. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components
6. **Explanation focus**: In "Why these elements matter", refer to the logical element (e.g., "Svg") not the technical implementation (e.g., "Div(NotStr(...))")
**Example of proper render test:**
```python
from myfasthtml.test.matcher import matches, find, Contains, NotStr, TestObject
from fasthtml.common import Div, Header, Button
3. **No inline comments**: Do NOT add comments on each line of the expected structure (except for structural clarification in global layout tests like `# left drawer`)
class TestControlRender:
def test_empty_control_is_rendered(self, root_instance):
"""Test that control renders with main structural sections.
4. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components
Why these elements matter:
- 3 children: Verifies header, body, and footer are all rendered
- _id: Essential for HTMX targeting and component identification
- cls="control-wrapper": Root CSS class for styling
"""
control = MyControl(root_instance)
5. **Test organization for Controls**: Organize tests into thematic classes:
- `TestControlBehaviour`: Tests for control behavior and logic
- `TestControlRender`: Tests for control HTML rendering
expected = Div(
Header(),
Div(),
Div(),
_id=control._id,
cls="control-wrapper"
)
6. **Fixture usage**: In `TestControlRender`, use a pytest fixture to create the control instance:
```python
class TestControlRender:
@pytest.fixture
def layout(self, root_instance):
return Layout(root_instance, app_name="Test App")
assert matches(control.render(), expected)
def test_something(self, layout):
# layout is injected automatically
```
def test_header_with_icon_is_rendered(self, root_instance):
"""Test that header renders with action icon.
---
Why these elements matter:
- Svg: Action icon is essential for user interaction
- TestObject(ActionButton): ActionButton component must be present
- cls="header-bar": Required CSS class for header styling
"""
control = MyControl(root_instance)
header = control._mk_header()
#### **Summary: The 11 UTR-11 sub-rules**
expected = Header(
Div(NotStr(name="action_icon_20_regular")),
TestObject(ActionButton),
cls="header-bar"
)
**Prerequisite**
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
assert matches(header, expected)
```
**Test file structure**
- **UTR-11.1**: ⭐ Always start with a global structure test (FIRST TEST)
- **UTR-11.2**: Break down complex tests into numbered steps
- **UTR-11.3**: Three-step pattern for simple tests
**How to search**
- **UTR-11.4**: Prefer search by ID
- **UTR-11.5**: `find_one()` vs `find()` based on context
**How to specify**
- **UTR-11.6**: Always `Contains()` for `cls` and `style`
- **UTR-11.7**: `TestIcon()` or `TestIconNotStr()` to test icon presence
- **UTR-11.8**: `TestScript()` for JavaScript
**How to document**
- **UTR-11.9**: Justify the choice of tested elements
- **UTR-11.10**: Explicit messages for `assert len()`
---
**When proposing render tests:**
- Reference specific patterns from the documentation
- Explain why you chose to test certain elements and not others
- Justify the use of predicates vs exact values
- Always include "Why these elements matter" documentation
- Always include justification documentation (see UTR-11.9)
### UTR-12: Test Workflow
---
### UTR-12: Analyze Execution Flow Before Writing Tests
**Rule:** Before writing a test, trace the complete execution flow to understand side effects.
**Why:** Prevents writing tests based on incorrect assumptions about behavior.
**Example:**
```
Test: "content_is_cached_after_first_retrieval"
Flow: create_tab() → _add_or_update_tab() → state.ns_tabs_content[tab_id] = component
Conclusion: Cache is already filled after create_tab, test would be redundant
```
**Process:**
1. Identify the method being tested
2. Trace all method calls it makes
3. Identify state changes at each step
4. Verify your assumptions about what the test should validate
5. Only then write the test
---
### UTR-13: Prefer matches() for Content Verification
**Rule:** Even in behavior tests, use `matches()` to verify HTML content rather than `assert "text" in str(element)`.
**Why:** More robust, clearer error messages, consistent with render test patterns.
**Examples:**
```python
# ❌ FRAGILE - string matching
result = component._dynamic_get_content("nonexistent_id")
assert "Tab not found" in str(result)
# ✅ ROBUST - structural matching
result = component._dynamic_get_content("nonexistent_id")
assert matches(result, Div('Tab not found.'))
```
---
### UTR-14: Know FastHTML Attribute Names
**Rule:** FastHTML elements use HTML attribute names, not Python parameter names.
**Key differences:**
- Use `attrs.get('class')` not `attrs.get('cls')`
- Use `attrs.get('id')` for the ID
- Prefer `matches()` with predicates to avoid direct attribute access
**Examples:**
```python
# ❌ WRONG - Python parameter name
classes = element.attrs.get('cls', '') # Returns None or ''
# ✅ CORRECT - HTML attribute name
classes = element.attrs.get('class', '') # Returns actual classes
# ✅ BETTER - Use predicates with matches()
expected = Div(cls=Contains("active"))
assert matches(element, expected)
```
---
### UTR-15: Test Workflow
1. **Receive code to test** - User provides file path or code section
2. **Check existing tests** - Look for corresponding test file and read it if it exists
3. **Analyze code** - Read and understand implementation
4. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
5. **Propose test plan** - List new/missing tests with brief explanations
6. **Wait for approval** - User validates the test plan
7. **Implement tests** - Write all approved tests
8. **Verify** - Ensure tests follow naming conventions and structure
9. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
4. **Trace execution flow** - Apply UTR-12 to understand side effects
5. **Gap analysis** - If tests exist, identify what's missing; otherwise identify all scenarios
6. **Propose test plan** - List new/missing tests with brief explanations
7. **Wait for approval** - User validates the test plan
8. **Implement tests** - Write all approved tests
9. **Verify** - Ensure tests follow naming conventions and structure
10. **Ask before running** - Do NOT automatically run tests with pytest. Ask user first if they want to run the tests.
---
## Managing Rules

File diff suppressed because it is too large Load Diff

583
docs/Layout.md Normal file
View File

@@ -0,0 +1,583 @@
# Layout Component
## Introduction
The Layout component provides a complete application structure with fixed header and footer, a scrollable main content
area, and optional collapsible side drawers. It's designed to be the foundation of your FastHTML application's UI.
**Key features:**
- Fixed header and footer that stay visible while scrolling
- Collapsible left and right drawers for navigation, tools, or auxiliary content
- Resizable drawers with drag handles
- Automatic state persistence per session
- Single instance per session (singleton pattern)
**Common use cases:**
- Application with navigation sidebar
- Dashboard with tools panel
- Admin interface with settings drawer
- Documentation site with table of contents
## Quick Start
Here's a minimal example showing an application with a navigation sidebar:
```python
from fasthtml.common import *
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
# Create the layout instance
layout = Layout(parent=root_instance, app_name="My App")
# Add navigation items to the left drawer
layout.left_drawer.add(
mk.mk(Div("Home"), command=Command(...))
)
layout.left_drawer.add(
mk.mk(Div("About"), command=Command(...))
)
layout.left_drawer.add(
mk.mk(Div("Contact"), command=Command(...))
)
# Set the main content
layout.set_main(
Div(
H1("Welcome"),
P("This is the main content area")
)
)
# Render the layout
return layout
```
This creates a complete application layout with:
- A header displaying the app name and drawer toggle button
- A collapsible left drawer with interactive navigation items
- A main content area that updates when navigation items are clicked
- An empty footer
**Note:** Navigation items use commands to update the main content area without page reload. See the Commands section
below for details.
## Basic Usage
### Creating a Layout
The Layout component is a `SingleInstance`, meaning there's only one instance per session. Create it by providing a
parent instance and an application name:
```python
layout = Layout(parent=root_instance, app_name="My Application")
```
### Content Zones
The Layout provides six content zones where you can add components:
```
┌──────────────────────────────────────────────────────────┐
│ Header │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ header_left │ │ header_right │ │
│ └─────────────────┘ └─────────────────┘ │
├─────────┬────────────────────────────────────┬───────────┤
│ │ │ │
│ left │ │ right │
│ drawer │ Main Content │ drawer │
│ │ │ │
│ │ │ │
├─────────┴────────────────────────────────────┴───────────┤
│ Footer │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ footer_left │ │ footer_right │ │
│ └─────────────────┘ └─────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
**Zone details:**
| Zone | Typical Use |
|----------------|-----------------------------------------------|
| `header_left` | App logo, menu button, breadcrumbs |
| `header_right` | User profile, notifications, settings |
| `left_drawer` | Navigation menu, tree view, filters |
| `right_drawer` | Tools panel, properties inspector, debug info |
| `footer_left` | Copyright, legal links, version |
| `footer_right` | Status indicators, connection state |
### Adding Content to Zones
Use the `.add()` method to add components to any zone:
```python
# Header
layout.header_left.add(Div("Logo"))
layout.header_right.add(Div("User: Admin"))
# Drawers
layout.left_drawer.add(Div("Navigation"))
layout.right_drawer.add(Div("Tools"))
# Footer
layout.footer_left.add(Div("© 2024 My App"))
layout.footer_right.add(Div("v1.0.0"))
```
### Setting Main Content
The main content area displays your page content and can be updated dynamically:
```python
# Set initial content
layout.set_main(
Div(
H1("Dashboard"),
P("Welcome to your dashboard")
)
)
# Update later (typically via commands)
layout.set_main(
Div(
H1("Settings"),
P("Configure your preferences")
)
)
```
### Controlling Drawers
By default, both drawers are visible. The drawer state is managed automatically:
- Users can toggle drawers using the icon buttons in the header
- Users can resize drawers by dragging the handle
- Drawer state persists within the session
The initial drawer widths are:
- Left drawer: 250px
- Right drawer: 250px
These can be adjusted by users and the state is preserved automatically.
## Content System
### Understanding Groups
Each content zone (header_left, header_right, drawers, footer) supports **groups** to organize related items. Groups are
separated visually by dividers and can have optional labels.
### Adding Content to Groups
When adding content, you can optionally specify a group name:
```python
# Add items to different groups in the left drawer
layout.left_drawer.add(Div("Dashboard"), group="main")
layout.left_drawer.add(Div("Analytics"), group="main")
layout.left_drawer.add(Div("Settings"), group="preferences")
layout.left_drawer.add(Div("Profile"), group="preferences")
```
This creates two groups:
- **main**: Dashboard, Analytics
- **preferences**: Settings, Profile
A visual divider automatically appears between groups.
### Custom Group Labels
You can provide a custom FastHTML element to display as the group header:
```python
# Add a styled group header
layout.left_drawer.add_group(
"Navigation",
group_ft=Div("MAIN MENU", cls="font-bold text-sm opacity-60 px-2 py-1")
)
# Then add items to this group
layout.left_drawer.add(Div("Home"), group="Navigation")
layout.left_drawer.add(Div("About"), group="Navigation")
```
### Ungrouped Content
If you don't specify a group, content is added to the default (`None`) group:
```python
# These items are in the default group
layout.left_drawer.add(Div("Quick Action 1"))
layout.left_drawer.add(Div("Quick Action 2"))
```
### Preventing Duplicates
The Content system automatically prevents adding duplicate items based on their `id` attribute:
```python
item = Div("Unique Item", id="my-item")
layout.left_drawer.add(item)
layout.left_drawer.add(item) # Ignored - already added
```
### Group Rendering Options
Groups render differently depending on the zone:
**In drawers** (vertical layout):
- Groups stack vertically
- Dividers are horizontal lines
- Group labels appear above their content
**In header/footer** (horizontal layout):
- Groups arrange side-by-side
- Dividers are vertical lines
- Group labels are typically hidden
## Advanced Features
### Resizable Drawers
Both drawers can be resized by users via drag handles:
- **Drag handle location**:
- Left drawer: Right edge
- Right drawer: Left edge
- **Width constraints**: 150px (minimum) to 600px (maximum)
- **Persistence**: Resized width is automatically saved in the session state
Users can drag the handle to adjust drawer width. The new width is preserved throughout their session.
### Programmatic Drawer Control
You can control drawers programmatically using commands:
```python
# Toggle drawer visibility
toggle_left = layout.commands.toggle_drawer("left")
toggle_right = layout.commands.toggle_drawer("right")
# Update drawer width
update_left_width = layout.commands.update_drawer_width("left", width=300)
update_right_width = layout.commands.update_drawer_width("right", width=350)
```
These commands are typically used with buttons or other interactive elements:
```python
# Add a button to toggle the right drawer
button = mk.button("Toggle Tools", command=layout.commands.toggle_drawer("right"))
layout.header_right.add(button)
```
### State Persistence
The Layout automatically persists its state within the user's session:
| State Property | Description | Default |
|----------------------|---------------------------------|---------|
| `left_drawer_open` | Whether left drawer is visible | `True` |
| `right_drawer_open` | Whether right drawer is visible | `True` |
| `left_drawer_width` | Left drawer width in pixels | `250` |
| `right_drawer_width` | Right drawer width in pixels | `250` |
State changes (toggle, resize) are automatically saved and restored within the session.
### Dynamic Content Updates
Content zones can be updated dynamically during the session:
```python
# Initial setup
layout.left_drawer.add(Div("Item 1"))
# Later, add more items (e.g., in a command handler)
def add_dynamic_content():
layout.left_drawer.add(Div("New Item"), group="dynamic")
return layout.left_drawer # Return updated drawer for HTMX swap
```
**Note**: When updating content dynamically, you typically return the updated zone to trigger an HTMX swap.
### CSS Customization
The Layout uses CSS classes that you can customize:
| Class | Element |
|----------------------------|----------------------------------|
| `mf-layout` | Root layout container |
| `mf-layout-header` | Header section |
| `mf-layout-footer` | Footer section |
| `mf-layout-main` | Main content area |
| `mf-layout-drawer` | Drawer container |
| `mf-layout-left-drawer` | Left drawer specifically |
| `mf-layout-right-drawer` | Right drawer specifically |
| `mf-layout-drawer-content` | Scrollable content within drawer |
| `mf-resizer` | Resize handle |
| `mf-layout-group` | Content group wrapper |
You can override these classes in your custom CSS to change colors, spacing, or behavior.
### User Profile Integration
The Layout automatically includes a UserProfile component in the header right area. This component handles user
authentication display and logout functionality when auth is enabled.
## Examples
### Example 1: Dashboard with Navigation Sidebar
A typical dashboard application with a navigation menu in the left drawer:
```python
from fasthtml.common import *
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
# Create layout
layout = Layout(parent=root_instance, app_name="Analytics Dashboard")
# Navigation menu in left drawer
def show_dashboard():
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
return layout._mk_main()
def show_reports():
layout.set_main(Div(H1("Reports"), P("Detailed analytics reports")))
return layout._mk_main()
def show_settings():
layout.set_main(Div(H1("Settings"), P("Configure your preferences")))
return layout._mk_main()
# Add navigation items with groups
layout.left_drawer.add_group("main", group_ft=Div("MENU", cls="font-bold text-xs px-2 opacity-60"))
layout.left_drawer.add(mk.mk(Div("Dashboard"), command=Command("nav_dash", "Show dashboard", show_dashboard)),
group="main")
layout.left_drawer.add(mk.mk(Div("Reports"), command=Command("nav_reports", "Show reports", show_reports)),
group="main")
layout.left_drawer.add_group("config", group_ft=Div("CONFIGURATION", cls="font-bold text-xs px-2 opacity-60"))
layout.left_drawer.add(mk.mk(Div("Settings"), command=Command("nav_settings", "Show settings", show_settings)),
group="config")
# Header content
layout.header_left.add(Div("📊 Analytics", cls="font-bold"))
# Footer
layout.footer_left.add(Div("© 2024 Analytics Co."))
layout.footer_right.add(Div("v1.0.0"))
# Set initial main content
layout.set_main(Div(H1("Dashboard"), P("Overview of your metrics")))
```
### Example 2: Development Tool with Debug Panel
An application with development tools in the right drawer:
```python
from fasthtml.common import *
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.helpers import mk
# Create layout
layout = Layout(parent=root_instance, app_name="Dev Tools")
# Main content: code editor
layout.set_main(
Div(
H2("Code Editor"),
Textarea("# Write your code here", rows=20, cls="w-full font-mono")
)
)
# Right drawer: debug and tools
layout.right_drawer.add_group("debug", group_ft=Div("DEBUG INFO", cls="font-bold text-xs px-2 opacity-60"))
layout.right_drawer.add(Div("Console output here..."), group="debug")
layout.right_drawer.add(Div("Variables: x=10, y=20"), group="debug")
layout.right_drawer.add_group("tools", group_ft=Div("TOOLS", cls="font-bold text-xs px-2 opacity-60"))
layout.right_drawer.add(Button("Run Code"), group="tools")
layout.right_drawer.add(Button("Clear Console"), group="tools")
# Header
layout.header_left.add(Div("DevTools IDE"))
layout.header_right.add(Button("Save"))
```
### Example 3: Minimal Layout (Main Content Only)
A simple layout without drawers, focusing only on main content:
```python
from fasthtml.common import *
from myfasthtml.controls.Layout import Layout
# Create layout
layout = Layout(parent=root_instance, app_name="Simple Blog")
# Header
layout.header_left.add(Div("My Blog", cls="text-xl font-bold"))
layout.header_right.add(A("About", href="/about"))
# Main content
layout.set_main(
Article(
H1("Welcome to My Blog"),
P("This is a simple blog layout without side drawers."),
P("The focus is on the content in the center.")
)
)
# Footer
layout.footer_left.add(Div("© 2024 Blog Author"))
layout.footer_right.add(A("RSS", href="/rss"))
# Note: Drawers are present but can be collapsed by users if not needed
```
### Example 4: Dynamic Content Loading
Loading content dynamically based on user interaction:
```python
from fasthtml.common import *
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
layout = Layout(parent=root_instance, app_name="Dynamic App")
# Function that loads content dynamically
def load_page(page_name):
# Simulate loading different content
content = {
"home": Div(H1("Home"), P("Welcome to the home page")),
"profile": Div(H1("Profile"), P("User profile information")),
"settings": Div(H1("Settings"), P("Application settings")),
}
layout.set_main(content.get(page_name, Div("Page not found")))
return layout._mk_main()
# Create navigation commands
pages = ["home", "profile", "settings"]
for page in pages:
cmd = Command(f"load_{page}", f"Load {page} page", load_page, page)
layout.left_drawer.add(
mk.mk(Div(page.capitalize()), command=cmd)
)
# Set initial content
layout.set_main(Div(H1("Home"), P("Welcome to the home page")))
```
---
## Developer Reference
This section contains technical details for developers working on the Layout component itself.
### State
The Layout component maintains the following state properties:
| Name | Type | Description | Default |
|----------------------|---------|----------------------------------|---------|
| `left_drawer_open` | boolean | True if the left drawer is open | True |
| `right_drawer_open` | boolean | True if the right drawer is open | True |
| `left_drawer_width` | integer | Width of the left drawer | 250 |
| `right_drawer_width` | integer | Width of the right drawer | 250 |
### Commands
Available commands for programmatic control:
| Name | Description |
|-----------------------------------------|----------------------------------------------------------------------------------------|
| `toggle_drawer(side)` | Toggles the drawer on the specified side |
| `update_drawer_width(side, width=None)` | Updates the drawer width on the specified side. The width is given by the HTMX request |
### Public Methods
| Method | Description |
|---------------------|-----------------------------|
| `set_main(content)` | Sets the main content area |
| `render()` | Renders the complete layout |
### High Level Hierarchical Structure
```
Div(id="layout")
├── Header
│ ├── Div(id="layout_hl")
│ │ ├── Icon # Left drawer icon button
│ │ └── Div # Left content for the header
│ └── Div(id="layout_hr")
│ ├── Div # Right content for the header
│ └── UserProfile # user profile icon button
├── Div # Left Drawer
├── Main # Main content
├── Div # Right Drawer
├── Footer # Footer
└── Script # To initialize the resizing
```
### Element IDs
| Name | Description |
|-------------|-------------------------------------|
| `layout` | Root layout container (singleton) |
| `layout_h` | Header section (not currently used) |
| `layout_hl` | Header left side |
| `layout_hr` | Header right side |
| `layout_f` | Footer section (not currently used) |
| `layout_fl` | Footer left side |
| `layout_fr` | Footer right side |
| `layout_ld` | Left drawer |
| `layout_rd` | Right drawer |
### Internal Methods
These methods are used internally for rendering:
| Method | Description |
|---------------------------|--------------------------------------------------------|
| `_mk_header()` | Renders the header component |
| `_mk_footer()` | Renders the footer component |
| `_mk_main()` | Renders the main content area |
| `_mk_left_drawer()` | Renders the left drawer |
| `_mk_right_drawer()` | Renders the right drawer |
| `_mk_left_drawer_icon()` | Renders the left drawer toggle icon |
| `_mk_right_drawer_icon()` | Renders the right drawer toggle icon |
| `_mk_content_wrapper()` | Static method to wrap content with groups and dividers |
### Content Class
The `Layout.Content` nested class manages content zones:
| Method | Description |
|-----------------------------------|----------------------------------------------------------|
| `add(content, group=None)` | Adds content to a group, prevents duplicates based on ID |
| `add_group(group, group_ft=None)` | Creates a new group with optional custom header element |
| `get_content()` | Returns dictionary of groups and their content |
| `get_groups()` | Returns list of (group_name, group_ft) tuples |

622
docs/TabsManager.md Normal file
View File

@@ -0,0 +1,622 @@
# TabsManager Component
## Introduction
The TabsManager component provides a dynamic tabbed interface for organizing multiple views within your FastHTML application. It handles tab creation, activation, closing, and content management with automatic state persistence and HTMX-powered interactions.
**Key features:**
- Dynamic tab creation and removal at runtime
- Automatic content caching for performance
- Session-based state persistence (tabs, order, active tab)
- Duplicate tab detection based on component identity
- Built-in search menu for quick tab navigation
- Auto-increment labels for programmatic tab creation
- HTMX-powered updates without page reload
**Common use cases:**
- Multi-document editor (code editor, text editor)
- Dashboard with multiple data views
- Settings interface with different configuration panels
- Developer tools with console, inspector, network tabs
- Application with dynamic content sections
## Quick Start
Here's a minimal example showing a tabbed interface with three views:
```python
from fasthtml.common import *
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.core.instances import RootInstance
# Create root instance and tabs manager
root = RootInstance(session)
tabs = TabsManager(parent=root)
# Create three tabs with different content
tabs.create_tab("Dashboard", Div(H1("Dashboard"), P("Overview of your data")))
tabs.create_tab("Settings", Div(H1("Settings"), P("Configure your preferences")))
tabs.create_tab("Profile", Div(H1("Profile"), P("Manage your profile")))
# Render the tabs manager
return tabs
```
This creates a complete tabbed interface with:
- A header bar displaying three clickable tab buttons ("Dashboard", "Settings", "Profile")
- Close buttons (×) on each tab for dynamic removal
- A main content area showing the active tab's content
- A search menu (⊞ icon) for quick tab navigation when many tabs are open
- Automatic HTMX updates when switching or closing tabs
**Note:** Tabs are interactive by default. Users can click tab labels to switch views, click close buttons to remove tabs, or use the search menu to find tabs quickly. All interactions update the UI without page reload thanks to HTMX integration.
## Basic Usage
### Visual Structure
The TabsManager component consists of a header with tab buttons and a content area:
```
┌────────────────────────────────────────────────────────────┐
│ Tab Header │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────┐ │
│ │ Tab 1 × │ │ Tab 2 × │ │ Tab 3 × │ │ ⊞ │ │
│ └──────────┘ └──────────┘ └──────────┘ └────┘ │
├────────────────────────────────────────────────────────────┤
│ │
│ │
│ Active Tab Content │
│ │
│ │
└────────────────────────────────────────────────────────────┘
```
**Component details:**
| Element | Description |
|-------------------|--------------------------------------------------|
| Tab buttons | Clickable labels to switch between tabs |
| Close button (×) | Removes the tab and its content |
| Search menu (⊞) | Dropdown menu to search and filter tabs |
| Content area | Displays the active tab's content |
### Creating a TabsManager
The TabsManager is a `MultipleInstance`, meaning you can create multiple independent tab managers in your application. Create it by providing a parent instance:
```python
tabs = TabsManager(parent=root_instance)
# Or with a custom ID
tabs = TabsManager(parent=root_instance, _id="my-tabs")
```
### Creating Tabs
Use the `create_tab()` method to add a new tab:
```python
# Create a tab with custom content
tab_id = tabs.create_tab(
label="My Tab",
component=Div(H1("Content"), P("Tab content here"))
)
# Create with a MyFastHtml control
from myfasthtml.controls.VisNetwork import VisNetwork
network = VisNetwork(parent=tabs, nodes=nodes_data, edges=edges_data)
tab_id = tabs.create_tab("Network View", network)
# Create without activating immediately
tab_id = tabs.create_tab("Background Tab", content, activate=False)
```
**Parameters:**
- `label` (str): Display text shown in the tab button
- `component` (Any): Content to display in the tab (FastHTML elements or MyFastHtml controls)
- `activate` (bool): Whether to make this tab active immediately (default: True)
**Returns:** A unique `tab_id` (UUID string) that identifies the tab
### Showing Tabs
Use the `show_tab()` method to activate and display a tab:
```python
# Show a tab (makes it active and sends content to client if needed)
tabs.show_tab(tab_id)
# Show without activating (just send content to client)
tabs.show_tab(tab_id, activate=False)
```
**Parameters:**
- `tab_id` (str): The UUID of the tab to show
- `activate` (bool): Whether to make this tab active (default: True)
**Note:** The first time a tab is shown, its content is sent to the client and cached. Subsequent activations just toggle visibility without re-sending content.
### Closing Tabs
Use the `close_tab()` method to remove a tab:
```python
# Close a specific tab
tabs.close_tab(tab_id)
```
**What happens when closing:**
1. Tab is removed from the tab list and order
2. Content is removed from cache and client
3. If the closed tab was active, the first remaining tab becomes active
4. If no tabs remain, `active_tab` is set to `None`
### Changing Tab Content
Use the `change_tab_content()` method to update an existing tab's content and label:
```python
# Update tab content and label
new_content = Div(H1("Updated"), P("New content"))
tabs.change_tab_content(
tab_id=tab_id,
label="Updated Tab",
component=new_content,
activate=True
)
```
**Parameters:**
- `tab_id` (str): The UUID of the tab to update
- `label` (str): New label for the tab
- `component` (Any): New content to display
- `activate` (bool): Whether to activate the tab after updating (default: True)
**Note:** This method forces the new content to be sent to the client, even if the tab was already displayed.
## Advanced Features
### Auto-increment Labels
When creating multiple tabs programmatically, you can use auto-increment to generate unique labels:
```python
# Using the on_new_tab method with auto_increment
def create_multiple_tabs():
# Creates "Untitled_0", "Untitled_1", "Untitled_2"
tabs.on_new_tab("Untitled", content, auto_increment=True)
tabs.on_new_tab("Untitled", content, auto_increment=True)
tabs.on_new_tab("Untitled", content, auto_increment=True)
```
**How it works:**
- The TabsManager maintains an internal counter (`_tab_count`)
- When `auto_increment=True`, the counter value is appended to the label
- Counter increments with each auto-incremented tab creation
- Useful for "New Tab 1", "New Tab 2" patterns in editors or tools
### Duplicate Detection
The TabsManager automatically detects and reuses tabs with identical content to prevent duplicates:
```python
# Create a control instance
network = VisNetwork(parent=tabs, nodes=data, edges=edges)
# First call creates a new tab
tab_id_1 = tabs.create_tab("Network", network)
# Second call with same label and component returns existing tab_id
tab_id_2 = tabs.create_tab("Network", network)
# tab_id_1 == tab_id_2 (True - same tab!)
```
**Detection criteria:**
A tab is considered a duplicate if all three match:
- Same `label`
- Same `component_type` (component class prefix)
- Same `component_id` (component instance ID)
**Note:** This only works with `BaseInstance` components (MyFastHtml controls). Plain FastHTML elements don't have IDs and will always create new tabs.
### Dynamic Content Updates
You can update tabs dynamically during the session:
```python
# Initial tab creation
tab_id = tabs.create_tab("Data View", Div("Loading..."))
# Later, update with actual data
def load_data():
data_content = Div(H2("Data"), P("Loaded content"))
tabs.change_tab_content(tab_id, "Data View", data_content)
# Returns HTMX response to update the UI
```
**Use cases:**
- Loading data asynchronously
- Refreshing tab content based on user actions
- Updating visualizations with new data
- Switching between different views in the same tab
### Tab Search Menu
The built-in search menu helps users navigate when many tabs are open:
```python
# The search menu is automatically created and includes:
# - A Search control for filtering tabs by label
# - Live filtering as you type
# - Click to activate a tab from search results
```
**How to access:**
- Click the ⊞ icon in the tab header
- Start typing to filter tabs by label
- Click a result to activate that tab
The search menu updates automatically when tabs are added or removed.
### HTMX Out-of-Band Swaps
For advanced HTMX control, you can customize swap behavior:
```python
# Standard behavior (out-of-band swap enabled)
tabs.show_tab(tab_id, oob=True) # Default
# Custom target behavior (disable out-of-band)
tabs.show_tab(tab_id, oob=False) # Swap into HTMX target only
```
**When to use `oob=False`:**
- When you want to control the exact HTMX target
- When combining with other HTMX responses
- When the tab activation is triggered by a command with a specific target
**When to use `oob=True` (default):**
- Most common use case
- Allows other controls to trigger tab changes without caring about targets
- Enables automatic UI updates across multiple elements
### CSS Customization
The TabsManager uses CSS classes that you can customize:
| Class | Element |
|----------------------------|----------------------------------|
| `mf-tabs-manager` | Root tabs manager container |
| `mf-tabs-header-wrapper` | Header wrapper (buttons + menu) |
| `mf-tabs-header` | Tab buttons container |
| `mf-tab-button` | Individual tab button |
| `mf-tab-active` | Active tab button (modifier) |
| `mf-tab-label` | Tab label text |
| `mf-tab-close-btn` | Close button (×) |
| `mf-tab-content-wrapper` | Content area container |
| `mf-tab-content` | Individual tab content |
| `mf-empty-content` | Empty state when no tabs |
**Example customization:**
```css
/* Change active tab color */
.mf-tab-active {
background-color: #3b82f6;
color: white;
}
/* Customize close button */
.mf-tab-close-btn:hover {
color: red;
}
/* Style the content area */
.mf-tab-content-wrapper {
padding: 2rem;
background-color: #f9fafb;
}
```
## Examples
### Example 1: Multi-view Application
A typical application with different views accessible through tabs:
```python
from fasthtml.common import *
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.core.instances import RootInstance
# Create tabs manager
root = RootInstance(session)
tabs = TabsManager(parent=root, _id="app-tabs")
# Dashboard view
dashboard = Div(
H1("Dashboard"),
Div(
Div("Total Users: 1,234", cls="stat"),
Div("Active Sessions: 56", cls="stat"),
Div("Revenue: $12,345", cls="stat"),
cls="stats-grid"
)
)
# Analytics view
analytics = Div(
H1("Analytics"),
P("Detailed analytics and reports"),
Div("Chart placeholder", cls="chart-container")
)
# Settings view
settings = Div(
H1("Settings"),
Form(
Label("Username:", Input(name="username", value="admin")),
Label("Email:", Input(name="email", value="admin@example.com")),
Button("Save", type="submit"),
)
)
# Create tabs
tabs.create_tab("Dashboard", dashboard)
tabs.create_tab("Analytics", analytics)
tabs.create_tab("Settings", settings)
# Render
return tabs
```
### Example 2: Dynamic Tabs with VisNetwork
Creating tabs dynamically with interactive network visualizations:
```python
from fasthtml.common import *
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import RootInstance
root = RootInstance(session)
tabs = TabsManager(parent=root, _id="network-tabs")
# Create initial tab with welcome message
tabs.create_tab("Welcome", Div(
H1("Network Visualizer"),
P("Click 'Add Network' to create a new network visualization")
))
# Function to create a new network tab
def add_network_tab():
# Define network data
nodes = [
{"id": 1, "label": "Node 1"},
{"id": 2, "label": "Node 2"},
{"id": 3, "label": "Node 3"}
]
edges = [
{"from": 1, "to": 2},
{"from": 2, "to": 3}
]
# Create network instance
network = VisNetwork(parent=tabs, nodes=nodes, edges=edges)
# Use auto-increment to create unique labels
return tabs.on_new_tab("Network", network, auto_increment=True)
# Create command for adding networks
add_cmd = Command("add_network", "Add network tab", add_network_tab)
# Add button to create new network tabs
add_button = mk.button("Add Network", command=add_cmd, cls="btn btn-primary")
# Return tabs and button
return Div(add_button, tabs)
```
### Example 3: Tab Management with Content Updates
An application that updates tab content based on user interaction:
```python
from fasthtml.common import *
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import RootInstance
root = RootInstance(session)
tabs = TabsManager(parent=root, _id="editor-tabs")
# Create initial document tabs
doc1_id = tabs.create_tab("Document 1", Textarea("Initial content 1", rows=10))
doc2_id = tabs.create_tab("Document 2", Textarea("Initial content 2", rows=10))
# Function to refresh a document's content
def refresh_document(tab_id, doc_name):
# Simulate loading new content
new_content = Textarea(f"Refreshed content for {doc_name}\nTimestamp: {datetime.now()}", rows=10)
tabs.change_tab_content(tab_id, doc_name, new_content)
return tabs._mk_tabs_controller(oob=True), tabs._mk_tabs_header_wrapper(oob=True)
# Create refresh commands
refresh_doc1 = Command("refresh_1", "Refresh doc 1", refresh_document, doc1_id, "Document 1")
refresh_doc2 = Command("refresh_2", "Refresh doc 2", refresh_document, doc2_id, "Document 2")
# Add refresh buttons
controls = Div(
mk.button("Refresh Document 1", command=refresh_doc1, cls="btn btn-sm"),
mk.button("Refresh Document 2", command=refresh_doc2, cls="btn btn-sm"),
cls="controls-bar"
)
return Div(controls, tabs)
```
### Example 4: Using Auto-increment for Dynamic Tabs
Creating multiple tabs programmatically with auto-generated labels:
```python
from fasthtml.common import *
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import RootInstance
root = RootInstance(session)
tabs = TabsManager(parent=root, _id="dynamic-tabs")
# Create initial placeholder tab
tabs.create_tab("Start", Div(
H2("Welcome"),
P("Click 'New Tab' to create numbered tabs")
))
# Function to create a new numbered tab
def create_numbered_tab():
content = Div(
H2("New Tab Content"),
P(f"This tab was created dynamically"),
Input(placeholder="Enter some text...", cls="input")
)
# Auto-increment creates "Tab_0", "Tab_1", "Tab_2", etc.
return tabs.on_new_tab("Tab", content, auto_increment=True)
# Create command
new_tab_cmd = Command("new_tab", "Create new tab", create_numbered_tab)
# Add button
new_tab_button = mk.button("New Tab", command=new_tab_cmd, cls="btn btn-primary")
return Div(
Div(new_tab_button, cls="toolbar"),
tabs
)
```
---
## Developer Reference
This section contains technical details for developers working on the TabsManager component itself.
### State
The TabsManager component maintains the following state properties:
| Name | Type | Description | Default |
|----------------------------|-----------------|------------------------------------------------------|---------|
| `tabs` | dict[str, Any] | Dictionary of tab metadata (id, label, component) | `{}` |
| `tabs_order` | list[str] | Ordered list of tab IDs | `[]` |
| `active_tab` | str \| None | ID of the currently active tab | `None` |
| `ns_tabs_content` | dict[str, Any] | Cache of tab content (raw, not wrapped) | `{}` |
| `ns_tabs_sent_to_client` | set | Set of tab IDs already sent to client | `set()` |
**Note:** Properties prefixed with `ns_` are not persisted in the database and exist only for the session.
### Commands
Available commands for programmatic control:
| Name | Description |
|-----------------------------------------|------------------------------------------------|
| `show_tab(tab_id)` | Activate or show a specific tab |
| `close_tab(tab_id)` | Close a specific tab |
| `add_tab(label, component, auto_increment)` | Add a new tab with optional auto-increment |
### Public Methods
| Method | Description |
|---------------------------------------------------------|------------------------------------------------------|
| `create_tab(label, component, activate=True)` | Create a new tab or reuse existing duplicate |
| `show_tab(tab_id, activate=True, oob=True)` | Send tab to client and/or activate it |
| `close_tab(tab_id)` | Close and remove a tab |
| `change_tab_content(tab_id, label, component, activate=True)` | Update existing tab's label and content |
| `on_new_tab(label, component, auto_increment=False)` | Create and show tab with auto-increment support |
| `add_tab_btn()` | Returns add tab button element |
| `get_state()` | Returns the TabsManagerState object |
| `render()` | Renders the complete TabsManager component |
### High Level Hierarchical Structure
```
Div(id="{id}", cls="mf-tabs-manager")
├── Div(id="{id}-controller") # Controller (hidden, manages active state)
├── Div(id="{id}-header-wrapper") # Header wrapper
│ ├── Div(id="{id}-header") # Tab buttons container
│ │ ├── Div (mf-tab-button) # Tab button 1
│ │ │ ├── Span (mf-tab-label) # Label (clickable)
│ │ │ └── Span (mf-tab-close-btn) # Close button
│ │ ├── Div (mf-tab-button) # Tab button 2
│ │ └── ...
│ └── Div (dropdown) # Search menu
│ ├── Icon (tabs24_regular) # Menu toggle button
│ └── Div (dropdown-content) # Search component
├── Div(id="{id}-content-wrapper") # Content wrapper
│ ├── Div(id="{id}-{tab_id_1}-content") # Tab 1 content
│ ├── Div(id="{id}-{tab_id_2}-content") # Tab 2 content
│ └── ...
└── Script # Initialization script
```
### Element IDs
| Name | Description |
|-------------------------------|------------------------------------------|
| `{id}` | Root tabs manager container |
| `{id}-controller` | Hidden controller managing active state |
| `{id}-header-wrapper` | Header wrapper (buttons + search) |
| `{id}-header` | Tab buttons container |
| `{id}-content-wrapper` | Content area wrapper |
| `{id}-{tab_id}-content` | Individual tab content |
| `{id}-search` | Search component ID |
**Note:** `{id}` is the TabsManager instance ID, `{tab_id}` is the UUID of each tab.
### Internal Methods
These methods are used internally for rendering:
| Method | Description |
|-----------------------------------------|-------------------------------------------------------|
| `_mk_tabs_controller(oob=False)` | Renders the hidden controller element |
| `_mk_tabs_header_wrapper(oob=False)` | Renders the header wrapper with buttons and search |
| `_mk_tab_button(tab_data)` | Renders a single tab button |
| `_mk_tab_content_wrapper()` | Renders the content wrapper with active tab content |
| `_mk_tab_content(tab_id, content)` | Renders individual tab content div |
| `_mk_show_tabs_menu()` | Renders the search dropdown menu |
| `_wrap_tab_content(tab_content)` | Wraps tab content for HTMX out-of-band insertion |
| `_get_or_create_tab_content(tab_id)` | Gets tab content from cache or creates it |
| `_dynamic_get_content(tab_id)` | Retrieves component from InstancesManager |
| `_tab_already_exists(label, component)` | Checks if duplicate tab exists |
| `_add_or_update_tab(...)` | Internal method to add/update tab in state |
| `_get_ordered_tabs()` | Returns tabs ordered by tabs_order list |
| `_get_tab_list()` | Returns list of tab dictionaries in order |
| `_get_tab_count()` | Returns and increments internal tab counter |
### Tab Metadata Structure
Each tab in the `tabs` dictionary has the following structure:
```python
{
'id': 'uuid-string', # Unique tab identifier
'label': 'Tab Label', # Display label
'component_type': 'prefix', # Component class prefix (or None)
'component_id': 'instance-id' # Component instance ID (or None)
}
```
**Note:** `component_type` and `component_id` are `None` for plain FastHTML elements that don't inherit from `BaseInstance`.

596
docs/TreeView.md Normal file
View File

@@ -0,0 +1,596 @@
# TreeView Component
## Introduction
The TreeView component provides an interactive hierarchical data visualization with full CRUD operations. It's designed for displaying tree-structured data like file systems, organizational charts, or navigation menus with inline editing capabilities.
**Key features:**
- Expand/collapse nodes with visual indicators
- Add child and sibling nodes dynamically
- Inline rename with keyboard support (ESC to cancel)
- Delete nodes (only leaf nodes without children)
- Node selection tracking
- Persistent state per session
- Configurable icons per node type
**Common use cases:**
- File/folder browser
- Category/subcategory management
- Organizational hierarchy viewer
- Navigation menu builder
- Document outline editor
## Quick Start
Here's a minimal example showing a file system tree:
```python
from fasthtml.common import *
from myfasthtml.controls.TreeView import TreeView, TreeNode
# Create TreeView instance
tree = TreeView(parent=root_instance, _id="file-tree")
# Add root folder
root = TreeNode(id="root", label="Documents", type="folder")
tree.add_node(root)
# Add some files
file1 = TreeNode(id="file1", label="report.pdf", type="file")
file2 = TreeNode(id="file2", label="budget.xlsx", type="file")
tree.add_node(file1, parent_id="root")
tree.add_node(file2, parent_id="root")
# Expand root to show children
tree.expand_all()
# Render the tree
return tree
```
This creates an interactive tree where users can:
- Click chevrons to expand/collapse folders
- Click labels to select items
- Use action buttons (visible on hover) to add, rename, or delete nodes
**Note:** All interactions use commands and update via HTMX without page reload.
## Basic Usage
### Creating a TreeView
TreeView is a `MultipleInstance`, allowing multiple trees per session. Create it with a parent instance:
```python
tree = TreeView(parent=root_instance, _id="my-tree")
```
### TreeNode Structure
Nodes are represented by the `TreeNode` dataclass:
```python
from myfasthtml.controls.TreeView import TreeNode
node = TreeNode(
id="unique-id", # Auto-generated UUID if not provided
label="Node Label", # Display text
type="default", # Type for icon mapping
parent=None, # Parent node ID (None for root)
children=[] # List of child node IDs
)
```
### Adding Nodes
Add nodes using the `add_node()` method:
```python
# Add root node
root = TreeNode(id="root", label="Root", type="folder")
tree.add_node(root)
# Add child node
child = TreeNode(label="Child 1", type="item")
tree.add_node(child, parent_id="root")
# Add with specific position
sibling = TreeNode(label="Child 2", type="item")
tree.add_node(sibling, parent_id="root", insert_index=0) # Insert at start
```
### Visual Structure
```
TreeView
├── Root Node 1
│ ├── [>] Child 1-1 # Collapsed node with children
│ ├── [ ] Child 1-2 # Leaf node (no children)
│ └── [v] Child 1-3 # Expanded node
│ ├── [ ] Grandchild
│ └── [ ] Grandchild
└── Root Node 2
└── [>] Child 2-1
```
**Legend:**
- `[>]` - Collapsed node (has children)
- `[v]` - Expanded node (has children)
- `[ ]` - Leaf node (no children)
### Expanding Nodes
Control node expansion programmatically:
```python
# Expand all nodes with children
tree.expand_all()
# Expand specific nodes by adding to opened list
tree._state.opened.append("node-id")
```
**Note:** Users can also toggle nodes by clicking the chevron icon.
## Interactive Features
### Node Selection
Users can select nodes by clicking on labels. The selected node is visually highlighted:
```python
# Programmatically select a node
tree._state.selected = "node-id"
# Check current selection
current = tree._state.selected
```
### Adding Nodes
Users can add nodes via action buttons (visible on hover):
**Add Child:**
- Adds a new node as a child of the target node
- Automatically expands the parent
- Creates node with same type as parent
**Add Sibling:**
- Adds a new node next to the target node (same parent)
- Inserts after the target node
- Cannot add sibling to root nodes
```python
# Programmatically add child
tree._add_child(parent_id="root", new_label="New Child")
# Programmatically add sibling
tree._add_sibling(node_id="child1", new_label="New Sibling")
```
### Renaming Nodes
Users can rename nodes via the edit button:
1. Click the edit icon (visible on hover)
2. Input field appears with current label
3. Press Enter to save (triggers command)
4. Press ESC to cancel (keyboard shortcut)
```python
# Programmatically start rename
tree._start_rename("node-id")
# Save rename
tree._save_rename("node-id", "New Label")
# Cancel rename
tree._cancel_rename()
```
### Deleting Nodes
Users can delete nodes via the delete button:
**Restrictions:**
- Can only delete leaf nodes (no children)
- Attempting to delete a node with children raises an error
- Deleted node is removed from parent's children list
```python
# Programmatically delete node
tree._delete_node("node-id") # Raises ValueError if node has children
```
## Content System
### Node Types and Icons
Assign types to nodes for semantic grouping and custom icon display:
```python
# Define node types
root = TreeNode(label="Project", type="project")
folder = TreeNode(label="src", type="folder")
file = TreeNode(label="main.py", type="python-file")
# Configure icons for types
tree.set_icon_config({
"project": "fluent.folder_open",
"folder": "fluent.folder",
"python-file": "fluent.document_python"
})
```
**Note:** Icon configuration is stored in state and persists within the session.
### Hierarchical Organization
Nodes automatically maintain parent-child relationships:
```python
# Get node's children
node = tree._state.items["node-id"]
child_ids = node.children
# Get node's parent
parent_id = node.parent
# Navigate tree programmatically
for child_id in node.children:
child_node = tree._state.items[child_id]
print(child_node.label)
```
### Finding Root Nodes
Root nodes are nodes without a parent:
```python
root_nodes = [
node_id for node_id, node in tree._state.items.items()
if node.parent is None
]
```
## Advanced Features
### Keyboard Shortcuts
TreeView includes keyboard support for common operations:
| Key | Action |
|-----|--------|
| `ESC` | Cancel rename operation |
Additional shortcuts can be added via the Keyboard component:
```python
from myfasthtml.controls.Keyboard import Keyboard
tree = TreeView(parent=root_instance)
# ESC handler is automatically included for cancel rename
```
### State Management
TreeView maintains persistent state within the session:
| State Property | Type | Description |
|----------------|------|-------------|
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID |
| `opened` | `list[str]` | IDs of expanded nodes |
| `selected` | `str \| None` | Currently selected node ID |
| `editing` | `str \| None` | Node being renamed (if any) |
| `icon_config` | `dict[str, str]` | Type-to-icon mapping |
### Dynamic Updates
TreeView updates are handled via commands that return the updated tree:
```python
# Commands automatically target the tree for HTMX swap
cmd = tree.commands.toggle_node("node-id")
# When executed, returns updated TreeView with new state
```
### CSS Customization
TreeView uses CSS classes for styling:
| Class | Element |
|-------|---------|
| `mf-treeview` | Root container |
| `mf-treenode-container` | Container for node and its children |
| `mf-treenode` | Individual node row |
| `mf-treenode.selected` | Selected node highlight |
| `mf-treenode-label` | Node label text |
| `mf-treenode-input` | Input field during rename |
| `mf-treenode-actions` | Action buttons container (hover) |
You can override these classes to customize appearance:
```css
.mf-treenode.selected {
background-color: #e0f2fe;
border-left: 3px solid #0284c7;
}
.mf-treenode-actions {
opacity: 0;
transition: opacity 0.2s;
}
.mf-treenode:hover .mf-treenode-actions {
opacity: 1;
}
```
## Examples
### Example 1: File System Browser
A file/folder browser with different node types:
```python
from fasthtml.common import *
from myfasthtml.controls.TreeView import TreeView, TreeNode
# Create tree
tree = TreeView(parent=root_instance, _id="file-browser")
# Configure icons
tree.set_icon_config({
"folder": "fluent.folder",
"python": "fluent.document_python",
"text": "fluent.document_text"
})
# Build file structure
root = TreeNode(id="root", label="my-project", type="folder")
tree.add_node(root)
src = TreeNode(id="src", label="src", type="folder")
tree.add_node(src, parent_id="root")
main = TreeNode(label="main.py", type="python")
utils = TreeNode(label="utils.py", type="python")
tree.add_node(main, parent_id="src")
tree.add_node(utils, parent_id="src")
readme = TreeNode(label="README.md", type="text")
tree.add_node(readme, parent_id="root")
# Expand to show structure
tree.expand_all()
return tree
```
### Example 2: Category Management
Managing product categories with inline editing:
```python
from fasthtml.common import *
from myfasthtml.controls.TreeView import TreeView, TreeNode
tree = TreeView(parent=root_instance, _id="categories")
# Root categories
electronics = TreeNode(id="elec", label="Electronics", type="category")
tree.add_node(electronics)
# Subcategories
computers = TreeNode(label="Computers", type="subcategory")
phones = TreeNode(label="Phones", type="subcategory")
tree.add_node(computers, parent_id="elec")
tree.add_node(phones, parent_id="elec")
# Products (leaf nodes)
laptop = TreeNode(label="Laptops", type="product")
desktop = TreeNode(label="Desktops", type="product")
tree.add_node(laptop, parent_id=computers.id)
tree.add_node(desktop, parent_id=computers.id)
tree.expand_all()
return tree
```
### Example 3: Document Outline Editor
Building a document outline with headings:
```python
from fasthtml.common import *
from myfasthtml.controls.TreeView import TreeView, TreeNode
tree = TreeView(parent=root_instance, _id="outline")
# Document structure
doc = TreeNode(id="doc", label="My Document", type="document")
tree.add_node(doc)
# Chapters
ch1 = TreeNode(id="ch1", label="Chapter 1: Introduction", type="heading1")
ch2 = TreeNode(id="ch2", label="Chapter 2: Methods", type="heading1")
tree.add_node(ch1, parent_id="doc")
tree.add_node(ch2, parent_id="doc")
# Sections
sec1_1 = TreeNode(label="1.1 Background", type="heading2")
sec1_2 = TreeNode(label="1.2 Objectives", type="heading2")
tree.add_node(sec1_1, parent_id="ch1")
tree.add_node(sec1_2, parent_id="ch1")
# Subsections
subsec = TreeNode(label="1.1.1 Historical Context", type="heading3")
tree.add_node(subsec, parent_id=sec1_1.id)
tree.expand_all()
return tree
```
### Example 4: Dynamic Tree with Event Handling
Responding to tree events with custom logic:
```python
from fasthtml.common import *
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
tree = TreeView(parent=root_instance, _id="dynamic-tree")
# Initial structure
root = TreeNode(id="root", label="Tasks", type="folder")
tree.add_node(root)
# Function to handle selection
def on_node_selected(node_id):
# Custom logic when node is selected
node = tree._state.items[node_id]
tree._select_node(node_id)
# Update a detail panel elsewhere in the UI
return Div(
H3(f"Selected: {node.label}"),
P(f"Type: {node.type}"),
P(f"Children: {len(node.children)}")
)
# Override select command with custom handler
# (In practice, you'd extend the Commands class or use event callbacks)
tree.expand_all()
return tree
```
---
## Developer Reference
This section contains technical details for developers working on the TreeView component itself.
### State
The TreeView component maintains the following state properties:
| Name | Type | Description | Default |
|------|------|-------------|---------|
| `items` | `dict[str, TreeNode]` | All nodes indexed by ID | `{}` |
| `opened` | `list[str]` | Expanded node IDs | `[]` |
| `selected` | `str \| None` | Selected node ID | `None` |
| `editing` | `str \| None` | Node being renamed | `None` |
| `icon_config` | `dict[str, str]` | Type-to-icon mapping | `{}` |
### Commands
Available commands for programmatic control:
| Name | Description |
|------|-------------|
| `toggle_node(node_id)` | Toggle expand/collapse state |
| `add_child(parent_id)` | Add child node to parent |
| `add_sibling(node_id)` | Add sibling node after target |
| `start_rename(node_id)` | Enter rename mode for node |
| `save_rename(node_id)` | Save renamed node label |
| `cancel_rename()` | Cancel rename operation |
| `delete_node(node_id)` | Delete node (if no children) |
| `select_node(node_id)` | Select a node |
All commands automatically target the TreeView component for HTMX updates.
### Public Methods
| Method | Description |
|--------|-------------|
| `add_node(node, parent_id, insert_index)` | Add a node to the tree |
| `expand_all()` | Expand all nodes with children |
| `set_icon_config(config)` | Configure icons for node types |
| `render()` | Render the complete TreeView |
### TreeNode Dataclass
```python
@dataclass
class TreeNode:
id: str # Unique identifier (auto-generated UUID)
label: str = "" # Display text
type: str = "default" # Node type for icon mapping
parent: Optional[str] = None # Parent node ID
children: list[str] = [] # Child node IDs
```
### High Level Hierarchical Structure
```
Div(id="treeview", cls="mf-treeview")
├── Div(cls="mf-treenode-container", data-node-id="root1")
│ ├── Div(cls="mf-treenode")
│ │ ├── Icon # Toggle chevron
│ │ ├── Span(cls="mf-treenode-label") | Input(cls="mf-treenode-input")
│ │ └── Div(cls="mf-treenode-actions")
│ │ ├── Icon # Add child
│ │ ├── Icon # Rename
│ │ └── Icon # Delete
│ └── Div(cls="mf-treenode-container") # Child nodes (if expanded)
│ └── ...
├── Div(cls="mf-treenode-container", data-node-id="root2")
│ └── ...
└── Keyboard # ESC handler
```
### Element IDs and Attributes
| Attribute | Element | Description |
|-----------|---------|-------------|
| `id` | Root Div | TreeView component ID |
| `data-node-id` | Node container | Node's unique ID |
### Internal Methods
These methods are used internally for rendering and state management:
| Method | Description |
|--------|-------------|
| `_toggle_node(node_id)` | Toggle expand/collapse state |
| `_add_child(parent_id, new_label)` | Add child node implementation |
| `_add_sibling(node_id, new_label)` | Add sibling node implementation |
| `_start_rename(node_id)` | Enter rename mode |
| `_save_rename(node_id, node_label)` | Save renamed node |
| `_cancel_rename()` | Cancel rename operation |
| `_delete_node(node_id)` | Delete node if no children |
| `_select_node(node_id)` | Select a node |
| `_render_action_buttons(node_id)` | Render hover action buttons |
| `_render_node(node_id, level)` | Recursively render node and children |
### Commands Class
The `Commands` nested class provides command factory methods:
| Method | Returns |
|--------|---------|
| `toggle_node(node_id)` | Command to toggle node |
| `add_child(parent_id)` | Command to add child |
| `add_sibling(node_id)` | Command to add sibling |
| `start_rename(node_id)` | Command to start rename |
| `save_rename(node_id)` | Command to save rename |
| `cancel_rename()` | Command to cancel rename |
| `delete_node(node_id)` | Command to delete node |
| `select_node(node_id)` | Command to select node |
All commands are automatically configured with HTMX targeting.
### Integration with Keyboard Component
TreeView includes a Keyboard component for ESC key handling:
```python
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="-keyboard")
```
This enables users to press ESC to cancel rename operations without clicking.

View File

@@ -38,7 +38,7 @@ mdurl==0.1.2
more-itertools==10.8.0
myauth==0.2.1
mydbengine==0.1.0
myutils==0.4.0
myutils==0.5.0
nh3==0.3.1
numpy==2.3.5
oauthlib==3.3.1

View File

@@ -2,8 +2,10 @@ import logging.config
import yaml
from fasthtml import serve
from fasthtml.components import Div
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
from myfasthtml.controls.DataGridsManager import DataGridsManager
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
@@ -44,38 +46,38 @@ def create_sample_treeview(parent):
TreeView: Configured TreeView instance with sample data
"""
tree_view = TreeView(parent, _id="-treeview")
# Create sample file structure
projects = TreeNode(label="Projects", type="folder")
tree_view.add_node(projects)
myfasthtml = TreeNode(label="MyFastHtml", type="folder")
tree_view.add_node(myfasthtml, parent_id=projects.id)
app_py = TreeNode(label="app.py", type="file")
tree_view.add_node(app_py, parent_id=myfasthtml.id)
readme = TreeNode(label="README.md", type="file")
tree_view.add_node(readme, parent_id=myfasthtml.id)
src_folder = TreeNode(label="src", type="folder")
tree_view.add_node(src_folder, parent_id=myfasthtml.id)
controls_py = TreeNode(label="controls.py", type="file")
tree_view.add_node(controls_py, parent_id=src_folder.id)
documents = TreeNode(label="Documents", type="folder")
tree_view.add_node(documents, parent_id=projects.id)
notes = TreeNode(label="notes.txt", type="file")
tree_view.add_node(notes, parent_id=documents.id)
todo = TreeNode(label="todo.md", type="file")
tree_view.add_node(todo, parent_id=documents.id)
# Expand all nodes to show the full structure
#tree_view.expand_all()
# tree_view.expand_all()
return tree_view
@@ -83,7 +85,7 @@ def create_sample_treeview(parent):
def index(session):
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
layout = Layout(session_instance, "Testing Layout")
layout.set_footer("Goodbye World")
layout.footer_left.add("Goodbye World")
tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
add_tab = tabs_manager.commands.add_tab
@@ -110,10 +112,10 @@ def index(session):
btn_popup = mk.label("Popup",
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
# Create TreeView with sample data
tree_view = create_sample_treeview(layout)
layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
@@ -121,7 +123,16 @@ def index(session):
layout.left_drawer.add(btn_file_upload, "Test")
layout.left_drawer.add(btn_popup, "Test")
layout.left_drawer.add(tree_view, "TreeView")
# data grids
dgs_manager = DataGridsManager(layout, _id="-datagrids")
layout.left_drawer.add_group("Documents", Div("Documents",
dgs_manager.mk_main_icons(),
cls="mf-layout-group flex gap-3"))
layout.left_drawer.add(dgs_manager, "Documents")
layout.set_main(tabs_manager)
# keyboard shortcuts
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")))

View File

@@ -3,6 +3,7 @@
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--spacing: 0.25rem;
--text-xs: 0.6875rem;
--text-sm: 0.875rem;
--text-sm--line-height: calc(1.25 / 0.875);
--text-xl: 1.25rem;
@@ -11,6 +12,8 @@
--radius-md: 0.375rem;
--default-font-family: var(--font-sans);
--default-mono-font-family: var(--font-mono);
--properties-font-size: var(--text-xs);
--mf-tooltip-zindex: 10;
}
@@ -56,6 +59,26 @@
* Compatible with DaisyUI 5
*/
.mf-tooltip-container {
background: var(--color-base-200);
padding: 5px 10px;
border-radius: 4px;
pointer-events: none; /* Prevent interfering with mouse events */
font-size: 12px;
white-space: nowrap;
opacity: 0; /* Default to invisible */
visibility: hidden; /* Prevent interaction when invisible */
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
position: fixed; /* Keep it above other content and adjust position */
z-index: var(--mf-tooltip-zindex); /* Ensure it's on top */
}
.mf-tooltip-container[data-visible="true"] {
opacity: 1;
visibility: visible; /* Show tooltip */
transition: opacity 0.3s ease; /* No delay when becoming visible */
}
/* Main layout container using CSS Grid */
.mf-layout {
display: grid;
@@ -422,7 +445,6 @@
/* Empty Content State */
.mf-empty-content {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
@@ -632,7 +654,6 @@
/* *************** Panel Component *************** */
/* *********************************************** */
/* Container principal du panel */
.mf-panel {
display: flex;
width: 100%;
@@ -641,7 +662,6 @@
position: relative;
}
/* Panel gauche */
.mf-panel-left {
position: relative;
flex-shrink: 0;
@@ -653,15 +673,13 @@
border-right: 1px solid var(--color-border-primary);
}
/* Panel principal (centre) */
.mf-panel-main {
flex: 1;
height: 100%;
overflow: auto;
min-width: 0; /* Important pour permettre le shrink du flexbox */
min-width: 0; /* Important to allow the shrinking of flexbox */
}
/* Panel droit */
.mf-panel-right {
position: relative;
flex-shrink: 0;
@@ -671,4 +689,94 @@
height: 100%;
overflow: auto;
border-left: 1px solid var(--color-border-primary);
padding: 0.5rem;
}
/* *********************************************** */
/* ************* Properties Component ************ */
/* *********************************************** */
/*!* Properties container *!*/
.mf-properties {
display: flex;
flex-direction: column;
gap: 1rem;
}
/*!* Group card - using DaisyUI card styling *!*/
.mf-properties-group-card {
background-color: var(--color-base-100);
border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
border-radius: var(--radius-md);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: auto;
}
.mf-properties-group-container {
display: inline-block; /* important */
min-width: max-content; /* important */
width: 100%;
}
/*!* Group header - gradient using DaisyUI primary color *!*/
.mf-properties-group-header {
background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%);
color: var(--color-primary-content);
padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75);
font-weight: 700;
font-size: var(--properties-font-size);
}
/*!* Group content area *!*/
.mf-properties-group-content {
display: flex;
flex-direction: column;
min-width: max-content;
}
/*!* Property row *!*/
.mf-properties-row {
display: grid;
grid-template-columns: 6rem 1fr;
align-items: center;
padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75);
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent);
transition: background-color 0.15s ease;
gap: calc(var(--properties-font-size) * 0.75);
}
.mf-properties-row:last-child {
border-bottom: none;
}
.mf-properties-row:hover {
background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);
}
/*!* Property key - normal font *!*/
.mf-properties-key {
align-items: start;
font-weight: 600;
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
flex: 0 0 40%;
font-size: var(--properties-font-size);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/*!* Property value - monospace font *!*/
.mf-properties-value {
font-family: var(--default-mono-font-family);
color: var(--color-base-content);
flex: 1;
text-align: left;
font-size: var(--properties-font-size);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -159,6 +159,113 @@ function initResizer(containerId, options = {}) {
});
}
function bindTooltipsWithDelegation(elementId) {
// To display the tooltip, the attribute 'data-tooltip' is mandatory => it contains the text to tooltip
// Then
// the 'truncate' to show only when the text is truncated
// the class 'mmt-tooltip' for force the display
console.info("bindTooltips on element " + elementId);
const element = document.getElementById(elementId);
const tooltipContainer = document.getElementById(`tt_${elementId}`);
if (!element) {
console.error(`Invalid element '${elementId}' container`);
return;
}
if (!tooltipContainer) {
console.error(`Invalid tooltip 'tt_${elementId}' container.`);
return;
}
// Add a single mouseenter and mouseleave listener to the parent element
element.addEventListener("mouseenter", (event) => {
//console.debug("Entering element", event.target)
const cell = event.target.closest("[data-tooltip]");
if (!cell) {
// console.debug(" No 'data-tooltip' attribute found. Stopping.");
return;
}
const no_tooltip = element.hasAttribute("mf-no-tooltip");
if (no_tooltip) {
// console.debug(" Attribute 'mmt-no-tooltip' found. Cancelling.");
return;
}
const content = cell.querySelector(".truncate") || cell;
const isOverflowing = content.scrollWidth > content.clientWidth;
const forceShow = cell.classList.contains("mf-tooltip");
if (isOverflowing || forceShow) {
const tooltipText = cell.getAttribute("data-tooltip");
if (tooltipText) {
const rect = cell.getBoundingClientRect();
const tooltipRect = tooltipContainer.getBoundingClientRect();
let top = rect.top - 30; // Above the cell
let left = rect.left;
// Adjust tooltip position to prevent it from going off-screen
if (top < 0) top = rect.bottom + 5; // Move below if no space above
if (left + tooltipRect.width > window.innerWidth) {
left = window.innerWidth - tooltipRect.width - 5; // Prevent overflow right
}
// Apply styles for tooltip positioning
requestAnimationFrame(() => {
tooltipContainer.textContent = tooltipText;
tooltipContainer.setAttribute("data-visible", "true");
tooltipContainer.style.top = `${top}px`;
tooltipContainer.style.left = `${left}px`;
});
}
}
}, true); // Use capture phase for better delegation if needed
element.addEventListener("mouseleave", (event) => {
const cell = event.target.closest("[data-tooltip]");
if (cell) {
tooltipContainer.setAttribute("data-visible", "false");
}
}, true); // Use capture phase for better delegation if needed
}
function initLayout(elementId) {
initResizer(elementId);
bindTooltipsWithDelegation(elementId);
}
function disableTooltip() {
const elementId = tooltipElementId
// console.debug("disableTooltip on element " + elementId);
const element = document.getElementById(elementId);
if (!element) {
console.error(`Invalid element '${elementId}' container`);
return;
}
element.setAttribute("mmt-no-tooltip", "");
}
function enableTooltip() {
const elementId = tooltipElementId
// console.debug("enableTooltip on element " + elementId);
const element = document.getElementById(elementId);
if (!element) {
console.error(`Invalid element '${elementId}' container`);
return;
}
element.removeAttribute("mmt-no-tooltip");
}
function initBoundaries(elementId, updateUrl) {
function updateBoundaries() {
const container = document.getElementById(elementId);
@@ -363,7 +470,6 @@ function updateTabs(controllerId) {
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) {
@@ -1354,4 +1460,5 @@ function updateTabs(controllerId) {
detachGlobalListener();
}
};
})();
})();

View File

@@ -17,6 +17,7 @@ class Commands(BaseCommands):
def update_boundaries(self):
return Command(f"{self._prefix}UpdateBoundaries",
"Update component boundaries",
self._owner,
self._owner.update_boundaries).htmx(target=f"{self._owner.get_id()}")

View File

@@ -5,6 +5,10 @@ from myfasthtml.core.network_utils import from_parent_child_list
class CommandsDebugger(SingleInstance):
"""
Represents a debugger designed for visualizing and managing commands in a parent-child
hierarchical structure.
"""
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)

View File

@@ -0,0 +1,65 @@
from typing import Optional
from fastcore.basics import NotStr
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
class DatagridState(DbObject):
def __init__(self, owner):
super().__init__(owner)
with self.initializing():
self.sidebar_visible: bool = False
self.selected_view: str = None
self.row_index: bool = False
self.columns: list[DataGridColumnState] = []
self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
self.headers: list[DataGridHeaderFooterConf] = []
self.footers: list[DataGridHeaderFooterConf] = []
self.sorted: list = []
self.filtered: dict = {}
self.edition: DatagridEditionState = DatagridEditionState()
self.selection: DatagridSelectionState = DatagridSelectionState()
self.html = None
class DatagridSettings(DbObject):
def __init__(self, owner):
super().__init__(owner)
with self.initializing():
self.file_name: Optional[str] = None
self.selected_sheet_name: Optional[str] = None
self.header_visible: bool = True
self.filter_all_visible: bool = True
self.views_visible: bool = True
self.open_file_visible: bool = True
self.open_settings_visible: bool = True
class Commands(BaseCommands):
pass
class DataGrid(MultipleInstance):
def __init__(self, parent, settings=None, _id=None):
super().__init__(parent, _id=_id)
self._settings = DatagridSettings(self)
self._state = DatagridState(self)
self.commands = Commands(self)
def set_html(self, html):
self._state.html = html
def render(self):
return Div(
NotStr(self._state.html),
id=self._id
)
def __ft__(self):
return self.render()

View File

@@ -0,0 +1,123 @@
from dataclasses import dataclass
import pandas as pd
from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance, InstancesManager
from myfasthtml.icons.fluent_p1 import table_add20_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular
@dataclass
class DocumentDefinition:
namespace: str
name: str
type: str
tab_id: str
datagrid_id: str
class DataGridsState(DbObject):
def __init__(self, owner, name=None):
super().__init__(owner, name=name)
with self.initializing():
self.elements: list = []
class Commands(BaseCommands):
def upload_from_source(self):
return Command("UploadFromSource",
"Upload from source",
self._owner,
self._owner.upload_from_source).htmx(target=None)
def new_grid(self):
return Command("NewGrid",
"New grid",
self._owner,
self._owner.new_grid)
def open_from_excel(self, tab_id, file_upload):
return Command("OpenFromExcel",
"Open from Excel",
self._owner,
self._owner.open_from_excel,
tab_id,
file_upload).htmx(target=f"#{self._owner._tree.get_id()}")
def clear_tree(self):
return Command("ClearTree",
"Clear tree",
self._owner,
self._owner.clear_tree).htmx(target=f"#{self._owner._tree.get_id()}")
class DataGridsManager(MultipleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self.commands = Commands(self)
self._state = DataGridsState(self)
self._tree = self._mk_tree()
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
def upload_from_source(self):
file_upload = FileUpload(self)
tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload)
file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload))
return self._tabs_manager.show_tab(tab_id)
def open_from_excel(self, tab_id, file_upload: FileUpload):
excel_content = file_upload.get_content()
df = pd.read_excel(excel_content, file_upload.get_sheet_name())
html = df.to_html(index=False)
dg = DataGrid(self._tabs_manager)
dg.set_html(html)
document = DocumentDefinition(
namespace=file_upload.get_file_basename(),
name=file_upload.get_sheet_name(),
type="excel",
tab_id=tab_id,
datagrid_id=None
)
self._state.elements = self._state.elements + [document]
parent_id = self._tree.ensure_path(document.namespace)
tree_node = TreeNode(label=document.name, type="excel", parent=parent_id)
self._tree.add_node(tree_node, parent_id=parent_id)
return self._mk_tree(), self._tabs_manager.change_tab_content(tab_id, document.name, Panel(self).set_main(dg))
def clear_tree(self):
self._state.elements = []
self._tree.clear()
return self._tree
def mk_main_icons(self):
return Div(
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
mk.icon(table_add20_regular, tooltip="New grid", command=self.commands.clear_tree()),
cls="flex"
)
def _mk_tree(self):
tree = TreeView(self, _id="-treeview")
for element in self._state.elements:
parent_id = tree.ensure_path(element.namespace)
tree.add_node(TreeNode(label=element.name, type=element.type, parent=parent_id))
return tree
def render(self):
return Div(
self._tree,
id=self._id,
)
def __ft__(self):
return self.render()

View File

@@ -10,10 +10,16 @@ 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")
return Command("Close",
"Close Dropdown",
self._owner,
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")
return Command("Click",
"Click on Dropdown",
self._owner,
self._owner.on_click).htmx(target=f"#{self._owner.get_id()}-content")
class DropdownState:
@@ -22,6 +28,12 @@ class DropdownState:
class Dropdown(MultipleInstance):
"""
Represents a dropdown component that can be toggled open or closed. This class is used
to create interactive dropdown elements, allowing for container and button customization.
The dropdown provides functionality to manage its state, including opening, closing, and
handling user interactions.
"""
def __init__(self, parent, content=None, button=None, _id=None):
super().__init__(parent, _id=_id)
self.button = Div(button) if not isinstance(button, FT) else button

View File

@@ -6,7 +6,7 @@ from fastapi import UploadFile
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.instances import MultipleInstance
@@ -24,32 +24,62 @@ class FileUploadState(DbObject):
self.ns_file_name: str | None = None
self.ns_sheets_names: list | None = None
self.ns_selected_sheet_name: str | None = None
self.ns_file_content: bytes | None = None
self.ns_on_ok = None
self.ns_on_cancel = 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}")
def on_file_uploaded(self):
return Command("UploadFile",
"Upload file",
self._owner,
self._owner.upload_file).htmx(target=f"#sn_{self._id}")
def on_sheet_selected(self):
return Command("SheetSelected",
"Sheet selected",
self._owner,
self._owner.select_sheet).htmx(target=f"#sn_{self._id}")
class FileUpload(MultipleInstance):
"""
Represents a file upload component.
This class provides functionality to handle the uploading process of a file,
extract sheet names from an Excel file, and enables users to select a specific
sheet for further processing. It integrates commands and state management
to ensure smooth operation within a parent application.
"""
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
def __init__(self, parent, _id=None, **kwargs):
super().__init__(parent, _id=_id, **kwargs)
self.commands = Commands(self)
self._state = FileUploadState(self)
self._state.ns_on_ok = None
def set_on_ok(self, callback):
self._state.ns_on_ok = callback
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_file_content = file.file.read()
self._state.ns_file_name = file.filename
self._state.ns_sheets_names = self.get_sheets_names(self._state.ns_file_content)
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
return self.mk_sheet_selector()
def select_sheet(self, sheet_name: str):
logger.debug(f"select_sheet: {sheet_name=}")
self._state.ns_selected_sheet_name = sheet_name
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(
@@ -57,12 +87,27 @@ class FileUpload(MultipleInstance):
selected=True if name == self._state.ns_selected_sheet_name else None,
) for name in self._state.ns_sheets_names]
return Select(
return mk.mk(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"
)
), command=self.commands.on_sheet_selected())
def get_content(self):
return self._state.ns_file_content
def get_file_name(self):
return self._state.ns_file_name
def get_file_basename(self):
if self._state.ns_file_name is None:
return None
return self._state.ns_file_name.split(".")[0]
def get_sheet_name(self):
return self._state.ns_selected_sheet_name
@staticmethod
def get_sheets_names(file_content):
@@ -86,12 +131,12 @@ class FileUpload(MultipleInstance):
hx_encoding='multipart/form-data',
cls="file-input file-input-bordered file-input-sm w-full",
),
command=self.commands.upload_file()
command=self.commands.on_file_uploaded()
),
self.mk_sheet_selector(),
cls="flex"
),
mk.dialog_buttons(),
mk.dialog_buttons(on_ok=self._state.ns_on_ok, on_cancel=self._state.ns_on_cancel),
)
def __ft__(self):

View File

@@ -1,5 +1,7 @@
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.Properties import Properties
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.network_utils import from_parent_child_list
@@ -8,12 +10,27 @@ class InstancesDebugger(SingleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self._panel = Panel(self, _id="-panel")
self._command = Command("ShowInstance",
"Display selected Instance",
self,
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
def render(self):
nodes, edges = self._get_nodes_and_edges()
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command})
return self._panel.set_main(vis_network)
def on_network_event(self, event_data: dict):
session, instance_id = event_data["nodes"][0].split("#")
properties_def = {"Main": {"Id": "_id", "Parent Id": "_parent._id"},
"State": {"_name": "_state._name", "*": "_state"},
"Commands": {"*": "commands"},
}
return self._panel.set_right(Properties(self,
InstancesManager.get(session, instance_id),
properties_def,
_id="-properties"))
def _get_nodes_and_edges(self):
instances = self._get_instances()
nodes, edges = from_parent_child_list(

View File

@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
class Keyboard(MultipleInstance):
"""
Represents a keyboard with customizable key combinations support.
The Keyboard class allows managing key combinations and their corresponding
actions for a given parent object.
"""
def __init__(self, parent, combinations=None, _id=None):
super().__init__(parent, _id=_id)
self.combinations = combinations or {}

View File

@@ -17,15 +17,17 @@ 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
from myfasthtml.icons.fluent import panel_left_contract20_regular as left_drawer_contract
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_expand
from myfasthtml.icons.fluent_p1 import panel_right_contract20_regular as right_drawer_contract
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_expand
logger = logging.getLogger("LayoutControl")
class LayoutState(DbObject):
def __init__(self, owner):
super().__init__(owner)
def __init__(self, owner, name=None):
super().__init__(owner, name=name)
with self.initializing():
self.left_drawer_open: bool = True
self.right_drawer_open: bool = True
@@ -35,24 +37,27 @@ class LayoutState(DbObject):
class Commands(BaseCommands):
def toggle_drawer(self, side: Literal["left", "right"]):
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
return Command("ToggleDrawer",
f"Toggle {side} layout drawer",
self._owner,
self._owner.toggle_drawer, side)
def update_drawer_width(self, side: Literal["left", "right"]):
def update_drawer_width(self, side: Literal["left", "right"], width: int = None):
"""
Create a command to update drawer width.
Args:
side: Which drawer to update ("left" or "right")
width: New width in pixels. Given by the HTMX request
Returns:
Command: Command object for updating drawer width
"""
return Command(
f"UpdateDrawerWidth_{side}",
f"Update {side} drawer width",
self._owner.update_drawer_width,
side
)
return Command(f"UpdateDrawerWidth_{side}",
f"Update {side} drawer width",
self._owner,
self._owner.update_drawer_width,
side)
class Layout(SingleInstance):
@@ -114,7 +119,7 @@ class Layout(SingleInstance):
# Content storage
self._main_content = None
self._state = LayoutState(self)
self._state = LayoutState(self, "default_layout")
self._boundaries = Boundaries(self)
self.commands = Commands(self)
self.left_drawer = self.Content(self)
@@ -123,16 +128,6 @@ class Layout(SingleInstance):
self.header_right = self.Content(self)
self.footer_left = self.Content(self)
self.footer_right = self.Content(self)
self._footer_content = None
def set_footer(self, content):
"""
Set the footer content.
Args:
content: FastHTML component(s) or content for the footer
"""
self._footer_content = content
def set_main(self, content):
"""
@@ -145,6 +140,18 @@ class Layout(SingleInstance):
return self
def toggle_drawer(self, side: Literal["left", "right"]):
"""
Toggle the state of a drawer (open or close) based on the specified side. This
method also generates the corresponding icon and drawer elements for the
selected side.
:param side: The side of the drawer to toggle. Must be either "left" or "right".
:type side: Literal["left", "right"]
:return: A tuple containing the updated drawer icon and drawer elements for
the specified side.
:rtype: Tuple[Any, Any]
:raises ValueError: If the provided `side` is not "left" or "right".
"""
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
if side == "left":
self._state.left_drawer_open = not self._state.left_drawer_open
@@ -190,15 +197,17 @@ class Layout(SingleInstance):
return Header(
Div( # left
self._mk_left_drawer_icon(),
*self.header_left.get_content(),
cls="flex gap-1"
*self._mk_content_wrapper(self.header_left, horizontal=True, show_group_name=False).children,
cls="flex gap-1",
id=f"{self._id}_hl"
),
Div( # right
*self.header_right.get_content()[None],
*self._mk_content_wrapper(self.header_right, horizontal=True, show_group_name=False).children,
UserProfile(self),
cls="flex gap-1"
cls="flex gap-1",
id=f"{self._id}_hr"
),
cls="mf-layout-header"
cls="mf-layout-header",
)
def _mk_footer(self):
@@ -208,9 +217,17 @@ class Layout(SingleInstance):
Returns:
Footer: FastHTML Footer component
"""
footer_content = self._footer_content if self._footer_content else ""
return Footer(
footer_content,
Div( # left
*self._mk_content_wrapper(self.footer_left, horizontal=True, show_group_name=False).children,
cls="flex gap-1",
id=f"{self._id}_fl"
),
Div( # right
*self._mk_content_wrapper(self.footer_right, horizontal=True, show_group_name=False).children,
cls="flex gap-1",
id=f"{self._id}_fr"
),
cls="mf-layout-footer footer sm:footer-horizontal"
)
@@ -277,7 +294,14 @@ class Layout(SingleInstance):
# Wrap content in scrollable container
content_wrapper = Div(
*self.right_drawer.get_content(),
*[
(
Div(cls="divider") if index > 0 else None,
group_ft,
*[item for item in self.right_drawer.get_content()[group_name]]
)
for index, (group_name, group_ft) in enumerate(self.right_drawer.get_groups())
],
cls="mf-layout-drawer-content"
)
@@ -290,15 +314,29 @@ class Layout(SingleInstance):
)
def _mk_left_drawer_icon(self):
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
return mk.icon(left_drawer_contract if self._state.left_drawer_open else left_drawer_expand,
id=f"{self._id}_ldi",
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,
return mk.icon(right_drawer_contract if self._state.right_drawer_open else right_drawer_expand,
id=f"{self._id}_rdi",
command=self.commands.toggle_drawer("right"))
@staticmethod
def _mk_content_wrapper(content: Content, show_group_name: bool = True, horizontal: bool = False):
return Div(
*[
(
Div(cls=f"divider {'divider-horizontal' if horizontal else ''}") if index > 0 else None,
group_ft if show_group_name else None,
*[item for item in content.get_content()[group_name]]
)
for index, (group_name, group_ft) in enumerate(content.get_groups())
],
cls="mf-layout-drawer-content"
)
def render(self):
"""
Render the complete layout.
@@ -309,12 +347,13 @@ class Layout(SingleInstance):
# Wrap everything in a container div
return Div(
Div(id=f"tt_{self._id}", cls="mf-tooltip-container"), # container for the tooltips
self._mk_header(),
self._mk_left_drawer(),
self._mk_main(),
self._mk_right_drawer(),
self._mk_footer(),
Script(f"initResizer('{self._id}');"),
Script(f"initLayout('{self._id}');"),
id=self._id,
cls="mf-layout",
)

View File

@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
class Mouse(MultipleInstance):
"""
Represents a mechanism to manage mouse event combinations and their associated commands.
This class is used to add, manage, and render mouse event sequences with corresponding
commands, providing a flexible way to handle mouse interactions programmatically.
"""
def __init__(self, parent, _id=None, combinations=None):
super().__init__(parent, _id=_id)
self.combinations = combinations or {}

View File

@@ -17,7 +17,11 @@ class PanelConf:
class Commands(BaseCommands):
def toggle_side(self, side: Literal["left", "right"]):
return Command("TogglePanelSide", f"Toggle {side} side panel", self._owner.toggle_side, side)
return Command("TogglePanelSide",
f"Toggle {side} side panel",
self._owner,
self._owner.toggle_side,
side)
def update_side_width(self, side: Literal["left", "right"]):
"""
@@ -29,15 +33,23 @@ class Commands(BaseCommands):
Returns:
Command: Command object for updating panel's side width
"""
return Command(
f"UpdatePanelSideWidth_{side}",
f"Update {side} side panel width",
self._owner.update_side_width,
side
)
return Command(f"UpdatePanelSideWidth_{side}",
f"Update {side} side panel width",
self._owner,
self._owner.update_side_width,
side)
class Panel(MultipleInstance):
"""
Represents a user interface panel that supports customizable left, main, and right components.
The `Panel` class is used to create and manage a panel layout with optional left, main,
and right sections. It provides functionality to set the components of the panel, toggle
sides, and adjust the width of the sides dynamically. The class also handles rendering
the panel with appropriate HTML elements and JavaScript for interactivity.
"""
def __init__(self, parent, conf=None, _id=None):
super().__init__(parent, _id=_id)
self.conf = conf or PanelConf()
@@ -58,35 +70,35 @@ class Panel(MultipleInstance):
def set_right(self, right):
self._right = right
return self
return Div(self._right, id=f"{self._id}_r")
def set_left(self, left):
self._left = left
return self
return Div(self._left, id=f"{self._id}_l")
def _mk_right(self):
if not self.conf.right:
return None
resizer = Div(
cls="mf-resizer mf-resizer-right",
data_command_id=self.commands.update_side_width("right").id,
data_side="right"
)
return Div(resizer, self._right, cls="mf-panel-right")
return Div(resizer, Div(self._right, id=f"{self._id}_r"), cls="mf-panel-right")
def _mk_left(self):
if not self.conf.left:
return None
resizer = Div(
cls="mf-resizer mf-resizer-left",
data_command_id=self.commands.update_side_width("left").id,
data_side="left"
)
return Div(self._left, resizer, cls="mf-panel-left")
return Div(Div(self._left, id=f"{self._id}_l"), resizer, cls="mf-panel-left")
def render(self):
return Div(

View File

@@ -0,0 +1,67 @@
from fasthtml.components import Div
from myutils.ProxyObject import ProxyObject
from myfasthtml.core.instances import MultipleInstance
class Properties(MultipleInstance):
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
super().__init__(parent, _id=_id)
self.obj = obj
self.groups = groups
self.properties_by_group = self._create_properties_by_group()
def set_obj(self, obj, groups: dict = None):
self.obj = obj
self.groups = groups
self.properties_by_group = self._create_properties_by_group()
def _mk_group_content(self, properties: dict):
return Div(
*[
Div(
Div(k, cls="mf-properties-key", data_tooltip=f"{k}"),
self._mk_property_value(v),
cls="mf-properties-row"
)
for k, v in properties.items()
],
cls="mf-properties-group-content"
)
def _mk_property_value(self, value):
if isinstance(value, dict):
return self._mk_group_content(value)
if isinstance(value, (list, tuple)):
return self._mk_group_content({i: item for i, item in enumerate(value)})
return Div(str(value),
cls="mf-properties-value",
title=str(value))
def render(self):
return Div(
*[
Div(
Div(
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
self._mk_group_content(proxy.as_dict()),
cls="mf-properties-group-container"
),
cls="mf-properties-group-card"
)
for group_name, proxy in self.properties_by_group.items()
],
id=self._id,
cls="mf-properties"
)
def _create_properties_by_group(self):
if self.groups is None:
return {None: ProxyObject(self.obj, {"*": ""})}
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()}
def __ft__(self):
return self.render()

View File

@@ -14,13 +14,31 @@ 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"))
return (Command("Search",
f"Search {self._owner.items_names}",
self._owner,
self._owner.on_search).htmx(target=f"#{self._owner.get_id()}-results",
trigger="keyup changed delay:300ms",
swap="innerHTML"))
class Search(MultipleInstance):
"""
Represents a component for managing and filtering a list of items.
It uses fuzzy matching and subsequence matching to filter items.
:ivar items_names: The name of the items used to filter.
:type items_names: str
:ivar items: The first set of items to filter.
:type items: list
:ivar filtered: A copy of the `items` list, representing the filtered items after a search operation.
:type filtered: list
:ivar get_attr: Callable function to extract string values from items for filtering.
:type get_attr: Callable[[Any], str]
:ivar template: Callable function to define how filtered items are rendered.
:type template: Callable[[Any], Any]
"""
def __init__(self,
parent: BaseInstance,
_id=None,
@@ -54,6 +72,12 @@ class Search(MultipleInstance):
self.filtered = self.items.copy()
return self
def get_items(self):
return self.items
def get_filtered(self):
return self.filtered
def on_search(self, query):
logger.debug(f"on_search {query=}")
self.search(query)

View File

@@ -52,32 +52,42 @@ class TabsManagerState(DbObject):
self.active_tab: str | None = None
# must not be persisted in DB
self._tabs_content: dict[str, Any] = {}
self.ns_tabs_content: dict[str, Any] = {} # Cache: always stores raw content (not wrapped)
self.ns_tabs_sent_to_client: set = set() # for tabs created, but not yet displayed
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")
self._owner,
self._owner.show_tab,
tab_id,
True,
False).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")
self._owner,
self._owner.close_tab,
tab_id).htmx(target=f"#{self._id}-controller", 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"))
return Command(f"{self._prefix}AddTab",
"Add a new tab",
self._owner,
self._owner.on_new_tab,
label,
component,
auto_increment).htmx(target=f"#{self._id}-controller", swap="outerHTML")
class TabsManager(MultipleInstance):
_tab_count = 0
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self._tab_count = 0
self._state = TabsManagerState(self)
self.commands = Commands(self)
self._boundaries = Boundaries()
@@ -86,6 +96,7 @@ class TabsManager(MultipleInstance):
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}")
@@ -96,18 +107,36 @@ class TabsManager(MultipleInstance):
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):
def _dynamic_get_content(self, tab_id):
if tab_id not in self._state.tabs:
return None
return Div("Tab not found.")
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"])
if tab_config["component"] is None:
return Div("Tab content does not support serialization.")
try:
return InstancesManager.get(self._session, tab_config["component"][1])
except Exception as e:
logger.error(f"Error while retrieving tab content: {e}")
return Div("Failed to retrieve tab content.")
@staticmethod
def _get_tab_count():
res = TabsManager._tab_count
TabsManager._tab_count += 1
def _get_or_create_tab_content(self, tab_id):
"""
Get tab content from cache or create it.
This method ensures content is always stored in raw form (not wrapped).
Args:
tab_id: ID of the tab
Returns:
Raw content component (not wrapped in Div)
"""
if tab_id not in self._state.ns_tabs_content:
self._state.ns_tabs_content[tab_id] = self._dynamic_get_content(tab_id)
return self._state.ns_tabs_content[tab_id]
def _get_tab_count(self):
res = self._tab_count
self._tab_count += 1
return res
def on_new_tab(self, label: str, component: Any, auto_increment=False):
@@ -116,20 +145,13 @@ class TabsManager(MultipleInstance):
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),
)
tab_id = self.create_tab(label, component)
return self.show_tab(tab_id, oob=False)
def add_tab(self, label: str, component: Any, activate: bool = True) -> str:
def create_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.
The tab is not yet sent to the client.
Args:
label: Display label for the tab
@@ -140,68 +162,55 @@ class TabsManager(MultipleInstance):
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())
tab_id = self._tab_already_exists(label, component) or str(uuid.uuid4())
self._add_or_update_tab(tab_id, label, component, activate)
return tab_id
def show_tab(self, tab_id):
def show_tab(self, tab_id, activate: bool = True, oob=True, is_new=True):
"""
Send the tab to the client if needed.
If the tab was already sent, just update the active tab.
:param tab_id:
:param activate:
:param oob: default=True so other control will not care of the target
:param is_new: is it a new tab or an existing one?
:return:
"""
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)
if activate:
self._state.active_tab = tab_id
# Get or create content (always stored in raw form)
content = self._get_or_create_tab_content(tab_id)
if tab_id not in self._state.ns_tabs_sent_to_client:
logger.debug(f" Content not in client memory. Sending it.")
self._state.ns_tabs_sent_to_client.add(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)
return (self._mk_tabs_controller(oob),
self._mk_tabs_header_wrapper(oob),
self._wrap_tab_content(tab_content, is_new))
else:
logger.debug(f" Content already exists. Just switch.")
return self._mk_tabs_controller()
logger.debug(f" Content already in client memory. Just switch.")
return self._mk_tabs_controller(oob) # no new tab_id => header is already up to date
def change_tab_content(self, tab_id, label, component, activate=True):
logger.debug(f"switch_tab {label=}, component={component}, activate={activate}")
if tab_id not in self._state.tabs:
logger.error(f" Tab {tab_id} not found. Cannot change its content.")
return None
self._add_or_update_tab(tab_id, label, component, activate)
self._state.ns_tabs_sent_to_client.discard(tab_id) # to make sure that the new content will be sent to the client
return self.show_tab(tab_id, activate=activate, oob=True, is_new=False)
def close_tab(self, tab_id: str):
"""
@@ -211,10 +220,12 @@ class TabsManager(MultipleInstance):
tab_id: ID of the tab to close
Returns:
Self for chaining
tuple: (controller, header_wrapper, content_to_remove) for HTMX swapping,
or self if tab not found
"""
logger.debug(f"close_tab {tab_id=}")
if tab_id not in self._state.tabs:
logger.debug(f" Tab not found.")
return self
# Copy state
@@ -225,8 +236,12 @@ class TabsManager(MultipleInstance):
state.tabs_order.remove(tab_id)
# Remove from content
if tab_id in state._tabs_content:
del state._tabs_content[tab_id]
if tab_id in state.ns_tabs_content:
del state.ns_tabs_content[tab_id]
# Remove from content sent
if tab_id in state.ns_tabs_sent_to_client:
state.ns_tabs_sent_to_client.remove(tab_id)
# If closing active tab, activate another one
if state.active_tab == tab_id:
@@ -240,7 +255,8 @@ class TabsManager(MultipleInstance):
self._state.update(state)
self._search.set_items(self._get_tab_list())
return self
content_to_remove = Div(id=f"{self._id}-{tab_id}-content", hx_swap_oob=f"delete")
return self._mk_tabs_controller(), self._mk_tabs_header_wrapper(), content_to_remove
def add_tab_btn(self):
return mk.icon(tab_add24_regular,
@@ -250,11 +266,12 @@ class TabsManager(MultipleInstance):
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_controller(self, oob=False):
return Div(id=f"{self._id}-controller",
data_active_tab=f"{self._state.active_tab}",
hx_on__after_settle=f'updateTabs("{self._id}-controller");',
hx_swap_oob="true" if oob else None,
)
def _mk_tabs_header_wrapper(self, oob=False):
# Create visible tab buttons
@@ -264,24 +281,20 @@ class TabsManager(MultipleInstance):
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"),
Div(*visible_tab_buttons, cls="mf-tabs-header", id=f"{self._id}-header"),
self._mk_show_tabs_menu(),
id=f"{self._id}-header-wrapper",
cls="mf-tabs-header-wrapper",
hx_swap_oob="true" if oob else None
)
def _mk_tab_button(self, tab_data: dict, in_dropdown: bool = False):
def _mk_tab_button(self, tab_data: dict):
"""
Create a single tab button with its label and close button.
Args:
tab_id: Unique identifier for the tab
tab_data: Dictionary containing tab information (label, component_type, etc.)
in_dropdown: Whether this tab is rendered in the dropdown menu
Returns:
Button element representing the tab
@@ -299,12 +312,10 @@ class TabsManager(MultipleInstance):
command=self.commands.show_tab(tab_id)
)
extra_cls = "mf-tab-in-dropdown" if in_dropdown else ""
return Div(
tab_label,
close_btn,
cls=f"mf-tab-button {extra_cls} {'mf-tab-active' if is_active else ''}",
cls=f"mf-tab-button {'mf-tab-active' if is_active else ''}",
data_tab_id=tab_id,
data_manager_id=self._id
)
@@ -316,15 +327,9 @@ class TabsManager(MultipleInstance):
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
content = self._get_or_create_tab_content(self._state.active_tab)
tab_content = self._mk_tab_content(self._state.active_tab, content)
else:
tab_content = self._mk_tab_content(None, None)
@@ -335,10 +340,13 @@ class TabsManager(MultipleInstance):
)
def _mk_tab_content(self, tab_id: str, content):
if tab_id is None:
return Div("No Content", cls="mf-empty-content mf-tab-content hidden")
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
cls=f"mf-tab-content {'hidden' if not is_active else ''}",
id=f"{self._id}-{tab_id}-content",
)
@@ -357,23 +365,26 @@ class TabsManager(MultipleInstance):
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 _wrap_tab_content(self, tab_content, is_new=True):
if is_new:
return Div(
tab_content,
hx_swap_oob=f"beforeend:#{self._id}-content-wrapper"
)
else:
tab_content.attrs["hx-swap-oob"] = "outerHTML"
return tab_content
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_type = component.get_prefix()
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
if (tab_data.get('component') == (component_type, component_id) and
tab_data.get('label') == label):
return tab_id
@@ -382,6 +393,43 @@ class TabsManager(MultipleInstance):
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 _add_or_update_tab(self, tab_id, label, component, activate):
state = self._state.copy()
# Extract component ID if the component has a get_id() method
component_type, component_id = None, None
parent_type, parent_id = None, None
if isinstance(component, BaseInstance):
component_type = component.get_prefix()
component_id = component.get_id()
parent = component.get_parent()
if parent:
parent_type = parent.get_prefix()
parent_id = parent.get_id()
# Add tab metadata to state
state.tabs[tab_id] = {
'id': tab_id,
'label': label,
'component': (component_type, component_id) if component_type else None,
'component_parent': (parent_type, parent_id) if parent_type else None
}
# Add tab to order list
if tab_id not in state.tabs_order:
state.tabs_order.append(tab_id)
# Add the content
state.ns_tabs_content[tab_id] = component
# Activate tab if requested
if activate:
state.active_tab = tab_id
# finally, update the state
self._state.update(state)
self._search.set_items(self._get_tab_list())
def update_boundaries(self):
return Script(f"updateBoundaries('{self._id}');")
@@ -396,6 +444,7 @@ class TabsManager(MultipleInstance):
self._mk_tabs_controller(),
self._mk_tabs_header_wrapper(),
self._mk_tab_content_wrapper(),
Script(f'updateTabs("{self._id}-controller");'), # first time, run the script to initialize the tabs
cls="mf-tabs-manager",
id=self._id,
)

View File

@@ -37,6 +37,7 @@ class TreeNode:
type: str = "default"
parent: Optional[str] = None
children: list[str] = field(default_factory=list)
bag: Optional[dict] = None # to keep extra info
class TreeViewState(DbObject):
@@ -66,74 +67,67 @@ class Commands(BaseCommands):
def toggle_node(self, node_id: str):
"""Create command to expand/collapse a node."""
return Command(
"ToggleNode",
f"Toggle node {node_id}",
self._owner._toggle_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
return Command("ToggleNode",
f"Toggle node {node_id}",
self._owner,
self._owner._toggle_node,
node_id).htmx(target=f"#{self._owner.get_id()}")
def add_child(self, parent_id: str):
"""Create command to add a child node."""
return Command(
"AddChild",
f"Add child to {parent_id}",
self._owner._add_child,
parent_id
).htmx(target=f"#{self._owner.get_id()}")
return Command("AddChild",
f"Add child to {parent_id}",
self._owner,
self._owner._add_child,
parent_id).htmx(target=f"#{self._owner.get_id()}")
def add_sibling(self, node_id: str):
"""Create command to add a sibling node."""
return Command(
"AddSibling",
f"Add sibling to {node_id}",
self._owner._add_sibling,
node_id
).htmx(target=f"#{self._owner.get_id()}")
return Command("AddSibling",
f"Add sibling to {node_id}",
self._owner,
self._owner._add_sibling,
node_id
).htmx(target=f"#{self._owner.get_id()}")
def start_rename(self, node_id: str):
"""Create command to start renaming a node."""
return Command(
"StartRename",
f"Start renaming {node_id}",
self._owner._start_rename,
node_id
).htmx(target=f"#{self._owner.get_id()}")
return Command("StartRename",
f"Start renaming {node_id}",
self._owner,
self._owner._start_rename,
node_id).htmx(target=f"#{self._owner.get_id()}")
def save_rename(self, node_id: str):
"""Create command to save renamed node."""
return Command(
"SaveRename",
f"Save rename for {node_id}",
self._owner._save_rename,
node_id
).htmx(target=f"#{self._owner.get_id()}")
return Command("SaveRename",
f"Save rename for {node_id}",
self._owner,
self._owner._save_rename,
node_id).htmx(target=f"#{self._owner.get_id()}")
def cancel_rename(self):
"""Create command to cancel renaming."""
return Command(
"CancelRename",
"Cancel rename",
self._owner._cancel_rename
).htmx(target=f"#{self._owner.get_id()}")
return Command("CancelRename",
"Cancel rename",
self._owner,
self._owner._cancel_rename).htmx(target=f"#{self._owner.get_id()}")
def delete_node(self, node_id: str):
"""Create command to delete a node."""
return Command(
"DeleteNode",
f"Delete node {node_id}",
self._owner._delete_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
return Command("DeleteNode",
f"Delete node {node_id}",
self._owner,
self._owner._delete_node,
node_id).htmx(target=f"#{self._owner.get_id()}")
def select_node(self, node_id: str):
"""Create command to select a node."""
return Command(
"SelectNode",
f"Select node {node_id}",
self._owner._select_node,
node_id
).htmx(target=f"#{self._owner.get_id()}")
return Command("SelectNode",
f"Select node {node_id}",
self._owner,
self._owner._select_node,
node_id).htmx(target=f"#{self._owner.get_id()}")
class TreeView(MultipleInstance):
@@ -173,7 +167,7 @@ class TreeView(MultipleInstance):
Format: {type: "provider.icon_name"}
"""
self._state.icon_config = config
def add_node(self, node: TreeNode, parent_id: Optional[str] = None, insert_index: Optional[int] = None):
"""
Add a node to the tree.
@@ -185,6 +179,9 @@ class TreeView(MultipleInstance):
If None, appends to end. If provided, inserts at that position.
"""
self._state.items[node.id] = node
if parent_id is None and node.parent is not None:
parent_id = node.parent
node.parent = parent_id
if parent_id and parent_id in self._state.items:
@@ -195,12 +192,66 @@ class TreeView(MultipleInstance):
else:
parent.children.append(node.id)
def ensure_path(self, path: str):
"""Add a node to the tree based on a path string.
Args:
path: Dot-separated path string (e.g., "folder1.folder2.file")
Raises:
ValueError: If path contains empty parts after stripping
"""
if path is None:
raise ValueError(f"Invalid path: path is None")
path = path.strip().strip(".")
if path == "":
raise ValueError(f"Invalid path: path is empty")
parent_id = None
current_nodes = [node for node in self._state.items.values() if node.parent is None]
path_parts = path.split(".")
for part in path_parts:
part = part.strip()
# Validate that part is not empty after stripping
if part == "":
raise ValueError(f"Invalid path: path contains empty parts")
node = [node for node in current_nodes if node.label == part]
if len(node) == 0:
# create the node
node = TreeNode(label=part, type="folder")
self.add_node(node, parent_id=parent_id)
else:
node = node[0]
current_nodes = [self._state.items[node_id] for node_id in node.children]
parent_id = node.id
return parent_id
def get_selected_id(self):
if self._state.selected is None:
return None
return self._state.items[self._state.selected].id
def expand_all(self):
"""Expand all nodes that have children."""
for node_id, node in self._state.items.items():
if node.children and node_id not in self._state.opened:
self._state.opened.append(node_id)
def clear(self):
state = self._state.copy()
state.items = {}
state.opened = []
state.selected = None
state.editing = None
self._state.update(state)
return self
def _toggle_node(self, node_id: str):
"""Toggle expand/collapse state of a node."""
if node_id in self._state.opened:
@@ -334,12 +385,11 @@ class TreeView(MultipleInstance):
# Toggle icon
toggle = mk.icon(
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else " ",
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else None,
command=self.commands.toggle_node(node_id))
# Label or input for editing
if is_editing:
# TODO: Bind input to save_rename (Enter) and cancel_rename (Escape)
label_element = mk.mk(Input(
name="node_label",
value=node.label,
@@ -357,7 +407,6 @@ class TreeView(MultipleInstance):
label_element,
self._render_action_buttons(node_id),
cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}",
data_node_id=node_id,
style=f"padding-left: {level * 20}px"
)
@@ -372,7 +421,8 @@ class TreeView(MultipleInstance):
return Div(
node_element,
*children_elements,
cls="mf-treenode-container"
cls="mf-treenode-container",
data_node_id=node_id,
)
def render(self):
@@ -390,7 +440,7 @@ class TreeView(MultipleInstance):
return Div(
*[self._render_node(node_id) for node_id in root_nodes],
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="_keyboard"),
Keyboard(self, {"esc": self.commands.cancel_rename()}, _id="-keyboard"),
id=self._id,
cls="mf-treeview"
)

View File

@@ -33,7 +33,10 @@ class UserProfileState:
class Commands(BaseCommands):
def update_dark_mode(self):
return Command("UpdateDarkMode", "Set the dark mode", self._owner.update_dark_mode).htmx(target=None)
return Command("UpdateDarkMode",
"Set the dark mode",
self._owner,
self._owner.update_dark_mode).htmx(target=None)
class UserProfile(SingleInstance):

View File

@@ -25,19 +25,33 @@ class VisNetworkState(DbObject):
},
"physics": {"enabled": True}
}
self.events_handlers: dict = {} # {event_name: command_url}
class VisNetwork(MultipleInstance):
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None, events_handlers=None):
super().__init__(parent, _id=_id)
logger.debug(f"VisNetwork created with id: {self._id}")
# possible events (expected in snake_case
# - select_node → selectNode
# - select → select
# - click → click
# - double_click → doubleClick
self._state = VisNetworkState(self)
self._update_state(nodes, edges, options)
# Convert Commands to URLs
handlers_htmx_options = {
event_name: command.ajax_htmx_options()
for event_name, command in events_handlers.items()
} if events_handlers else {}
self._update_state(nodes, edges, options, handlers_htmx_options)
def _update_state(self, nodes, edges, options):
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}")
if not nodes and not edges and not options:
def _update_state(self, nodes, edges, options, events_handlers=None):
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}, {events_handlers=}")
if not nodes and not edges and not options and not events_handlers:
return
state = self._state.copy()
@@ -47,6 +61,8 @@ class VisNetwork(MultipleInstance):
state.edges = edges
if options is not None:
state.options = options
if events_handlers is not None:
state.events_handlers = events_handlers
self._state.update(state)
@@ -70,6 +86,34 @@ class VisNetwork(MultipleInstance):
# Convert Python options to JS
js_options = json.dumps(self._state.options, indent=2)
# Map Python event names to vis-network event names
event_name_map = {
"select_node": "selectNode",
"select": "select",
"click": "click",
"double_click": "doubleClick"
}
# Generate event handlers JavaScript
event_handlers_js = ""
for event_name, command_htmx_options in self._state.events_handlers.items():
vis_event_name = event_name_map.get(event_name, event_name)
event_handlers_js += f"""
network.on('{vis_event_name}', function(params) {{
const event_data = {{
event_name: '{event_name}',
nodes: params.nodes,
edges: params.edges,
pointer: params.pointer
}};
htmx.ajax('POST', '{command_htmx_options['url']}', {{
values: {{event_data: JSON.stringify(event_data)}},
target: '{command_htmx_options['target']}',
swap: '{command_htmx_options['swap']}'
}});
}});
"""
return (
Div(
id=self._id,
@@ -92,6 +136,7 @@ class VisNetwork(MultipleInstance):
}};
const options = {js_options};
const network = new vis.Network(container, data, options);
{event_handlers_js}
}})();
""")
)

View File

@@ -0,0 +1,49 @@
from dataclasses import dataclass, field
from myfasthtml.core.constants import ColumnType, DEFAULT_COLUMN_WIDTH, ViewType
@dataclass
class DataGridRowState:
row_id: int
visible: bool = True
height: int | None = None
@dataclass
class DataGridColumnState:
col_id: str # name of the column: cannot be changed
col_index: int # index of the column in the dataframe: cannot be changed
title: str = None
type: ColumnType = ColumnType.Text
visible: bool = True
usable: bool = True
width: int = DEFAULT_COLUMN_WIDTH
@dataclass
class DatagridEditionState:
under_edition: tuple[int, int] | None = None
previous_under_edition: tuple[int, int] | None = None
@dataclass
class DatagridSelectionState:
selected: tuple[int, int] | None = None
last_selected: tuple[int, int] | None = None
selection_mode: str = None # valid values are "row", "column" or None for "cell"
extra_selected: list[tuple[str, str | int]] = field(default_factory=list) # list(tuple(selection_mode, element_id))
last_extra_selected: tuple[int, int] = None
@dataclass
class DataGridHeaderFooterConf:
conf: dict[str, str] = field(default_factory=dict) # first 'str' is the column id
@dataclass
class DatagridView:
name: str
type: ViewType = ViewType.Table
columns: list[DataGridColumnState] = None

View File

@@ -50,6 +50,7 @@ class mk:
size=20,
can_select=True,
can_hover=False,
tooltip=None,
cls='',
command: Command = None,
binding: Binding = None,
@@ -65,6 +66,7 @@ class mk:
:param size: The size of the icon, specified in pixels. Defaults to 20.
:param can_select: Indicates whether the icon can be selected. Defaults to True.
:param can_hover: Indicates whether the icon reacts to hovering. Defaults to False.
:param tooltip:
:param cls: A string of custom CSS classes to be added to the icon container.
:param command: The command object defining the function to be executed on icon interaction.
:param binding: The binding object for configuring additional event listeners on the icon.
@@ -79,6 +81,10 @@ class mk:
cls,
kwargs)
if tooltip:
merged_cls = merge_classes(merged_cls, "mf-tooltip")
kwargs["data-tooltip"] = tooltip
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
@staticmethod

View File

@@ -1,4 +1,5 @@
import inspect
import json
import uuid
from typing import Optional
@@ -24,16 +25,18 @@ class BaseCommand:
:type description: str
"""
def __init__(self, name, description):
def __init__(self, name, description, owner=None, auto_register=True):
self.id = uuid.uuid4()
self.name = name
self.description = description
self.owner = owner
self._htmx_extra = {}
self._bindings = []
self._ft = None
# register the command
CommandsManager.register(self)
if auto_register:
CommandsManager.register(self)
def get_htmx_params(self):
return {
@@ -97,6 +100,14 @@ class BaseCommand:
def url(self):
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
def ajax_htmx_options(self):
return {
"url": self.url,
"target": self._htmx_extra.get("hx-target", "this"),
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
"values": {}
}
def get_ft(self):
return self._ft
@@ -123,14 +134,14 @@ class Command(BaseCommand):
:type kwargs: dict
"""
def __init__(self, name, description, callback, *args, **kwargs):
super().__init__(name, description)
def __init__(self, name, description, owner, callback, *args, **kwargs):
super().__init__(name, description, owner=owner)
self.callback = callback
self.callback_parameters = dict(inspect.signature(callback).parameters)
self.callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
self.args = args
self.kwargs = kwargs
def _convert(self, key, value):
def _cast_parameter(self, key, value):
if key in self.callback_parameters:
param = self.callback_parameters[key]
if param.annotation == bool:
@@ -141,8 +152,17 @@ class Command(BaseCommand):
return float(value)
elif param.annotation == list:
return value.split(",")
elif param.annotation == dict:
return json.loads(value)
return value
def ajax_htmx_options(self):
res = super().ajax_htmx_options()
if self.kwargs:
res["values"] |= self.kwargs
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
return res
def execute(self, client_response: dict = None):
ret_from_bindings = []
@@ -156,7 +176,7 @@ class Command(BaseCommand):
if client_response:
for k, v in client_response.items():
if k in self.callback_parameters:
new_kwargs[k] = self._convert(k, v)
new_kwargs[k] = self._cast_parameter(k, v)
if 'client_response' in self.callback_parameters:
new_kwargs['client_response'] = client_response
@@ -168,7 +188,9 @@ class Command(BaseCommand):
# Set the hx-swap-oob attribute on all elements returned by the callback
if isinstance(ret, (list, tuple)):
for r in ret[1:]:
if hasattr(r, 'attrs') and r.get("id", None) is not None:
if (hasattr(r, 'attrs')
and "hx-swap-oob" not in 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:
@@ -181,8 +203,8 @@ class Command(BaseCommand):
class LambdaCommand(Command):
def __init__(self, delegate, name="LambdaCommand", description="Lambda Command"):
super().__init__(name, description, delegate)
def __init__(self, owner, delegate, name="LambdaCommand", description="Lambda Command"):
super().__init__(name, description, owner, delegate)
self.htmx(target=None)
def execute(self, client_response: dict = None):

View File

@@ -1,5 +1,39 @@
from enum import Enum
DEFAULT_COLUMN_WIDTH = 100
ROUTE_ROOT = "/myfasthtml"
class Routes:
Commands = "/commands"
Bindings = "/bindings"
Bindings = "/bindings"
class ColumnType(Enum):
RowIndex = "RowIndex"
Text = "Text"
Number = "Number"
Datetime = "DateTime"
Bool = "Boolean"
Choice = "Choice"
List = "List"
class ViewType(Enum):
Table = "Table"
Chart = "Chart"
Form = "Form"
class FooterAggregation(Enum):
Sum = "Sum"
Mean = "Mean"
Min = "Min"
Max = "Max"
Count = "Count"
FilteredSum = "FilteredSum"
FilteredMean = "FilteredMean"
FilteredMin = "FilteredMin"
FilteredMax = "FilteredMax"
FilteredCount = "FilteredCount"

View File

@@ -39,7 +39,7 @@ class DbObject:
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
self._owner = owner
self._name = name or self.__class__.__name__
self._name = name or owner.get_full_id()
self._db_manager = db_manager or DbManager(self._owner)
self._finalize_initialization()
@@ -98,6 +98,8 @@ class DbObject:
properties = {}
if args:
arg = args[0]
if arg is None:
return self
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
@@ -112,6 +114,7 @@ class DbObject:
setattr(self, k, v)
self._save_self()
self._initializing = old_state
return self
def copy(self):
as_dict = self._get_properties().copy()

View File

@@ -84,7 +84,7 @@ class BaseInstance:
return self._prefix
def get_full_id(self) -> str:
return f"{InstancesManager.get_session_id(self._session)}-{self._id}"
return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
def get_full_parent_id(self) -> Optional[str]:
parent = self.get_parent()
@@ -176,11 +176,22 @@ class InstancesManager:
:param instance_id:
:return:
"""
key = (InstancesManager.get_session_id(session), instance_id)
session_id = InstancesManager.get_session_id(session)
key = (session_id, instance_id)
return InstancesManager.instances[key]
@staticmethod
def get_by_type(session: dict, cls: type):
session_id = InstancesManager.get_session_id(session)
res = [i for s, i in InstancesManager.instances.items() if s[0] == session_id and isinstance(i, cls)]
assert len(res) <= 1, f"Multiple instances of type {cls.__name__} found"
assert len(res) > 0, f"No instance of type {cls.__name__} found"
return res[0]
@staticmethod
def get_session_id(session):
if isinstance(session, str):
return session
if session is None:
return "** NOT LOGGED IN **"
if "user_info" not in session:

View File

@@ -3,6 +3,7 @@ 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.

View File

@@ -49,8 +49,14 @@ def get():
mk.manage_binding(datalist, Binding(data))
mk.manage_binding(label_elt, Binding(data))
add_button = mk.button("Add", command=Command("Add", "Add a suggestion", add_suggestion).bind(data))
remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion).bind(data))
add_button = mk.button("Add", command=Command("Add",
"Add a suggestion",
None,
add_suggestion).bind(data))
remove_button = mk.button("Remove", command=Command("Remove",
"Remove a suggestion",
None,
remove_suggestion).bind(data))
return Div(
add_button,

View File

@@ -11,7 +11,10 @@ def say_hello():
# Create the command
hello_command = Command("say_hello", "Responds with a greeting", say_hello)
hello_command = Command("say_hello",
"Responds with a greeting",
None,
say_hello)
# Create the app
app, rt = create_app(protect_routes=False)

View File

@@ -13,7 +13,10 @@ def change_text():
return "New text"
command = Command("change_text", "change the text", change_text).htmx(target="#text")
command = Command("change_text",
"change the text",
None,
change_text).htmx(target="#text")
@rt("/")

View File

@@ -1,9 +1,12 @@
import re
from dataclasses import dataclass
from typing import Optional, Any
from fastcore.basics import NotStr
from fastcore.xml import FT
from myfasthtml.core.utils import quoted_str
from myfasthtml.core.commands import BaseCommand
from myfasthtml.core.utils import quoted_str, snake_to_pascal
from myfasthtml.test.testclient import MyFT
MISSING_ATTR = "** MISSING **"
@@ -17,7 +20,18 @@ class Predicate:
raise NotImplementedError
def __str__(self):
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
if self.value is None:
str_value = ''
elif isinstance(self.value, str):
str_value = self.value
elif isinstance(self.value, (list, tuple)):
if len(self.value) == 1:
str_value = self.value[0]
else:
str_value = str(self.value)
else:
str_value = str(self.value)
return f"{self.__class__.__name__}({str_value})"
def __repr__(self):
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
@@ -47,20 +61,33 @@ class StartsWith(AttrPredicate):
return actual.startswith(self.value)
class Contains(AttrPredicate):
class EndsWith(AttrPredicate):
def __init__(self, value):
super().__init__(value)
def validate(self, actual):
return self.value in actual
return actual.endswith(self.value)
class Contains(AttrPredicate):
def __init__(self, *value, _word=False):
super().__init__(value)
self._word = _word
def validate(self, actual):
if self._word:
words = actual.split()
return all(val in words for val in self.value)
else:
return all(val in actual for val in self.value)
class DoesNotContain(AttrPredicate):
def __init__(self, value):
def __init__(self, *value):
super().__init__(value)
def validate(self, actual):
return self.value not in actual
return all(val not in actual for val in self.value)
class AnyValue(AttrPredicate):
@@ -75,6 +102,22 @@ class AnyValue(AttrPredicate):
return actual is not None
class Regex(AttrPredicate):
def __init__(self, pattern):
super().__init__(pattern)
def validate(self, actual):
return re.match(self.value, actual) is not None
class And(AttrPredicate):
def __init__(self, *predicates):
super().__init__(predicates)
def validate(self, actual):
return all(p.validate(actual) for p in self.value)
class ChildrenPredicate(Predicate):
"""
Predicate given as a child of an element.
@@ -116,27 +159,86 @@ class AttributeForbidden(ChildrenPredicate):
return element
class HasHtmx(ChildrenPredicate):
def __init__(self, command: BaseCommand = None, **htmx_params):
super().__init__(None)
self.command = command
if command:
self.htmx_params = command.get_htmx_params() | htmx_params
else:
self.htmx_params = htmx_params
self.htmx_params = {k.replace("hx_", "hx-"): v for k, v in self.htmx_params.items()}
def validate(self, actual):
return all(actual.attrs.get(k) == v for k, v in self.htmx_params.items())
def to_debug(self, element):
for k, v in self.htmx_params.items():
element.attrs[k] = v
return element
class TestObject:
def __init__(self, cls, **kwargs):
self.cls = cls
self.attrs = kwargs
class TestIcon(TestObject):
def __init__(self, name: Optional[str] = '', command=None):
super().__init__("div")
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
self.children = [
TestObject(NotStr, s=Regex(f'<svg name="\\w+-{self.name}'))
]
if command:
self.attrs |= command.get_htmx_params()
def __str__(self):
return f'<div><svg name="{self.name}" .../></div>'
class TestIconNotStr(TestObject):
def __init__(self, name: Optional[str] = ''):
super().__init__(NotStr)
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
self.attrs["s"] = Regex(f'<svg name="\\w+-{self.name}')
def __str__(self):
return f'<svg name="{self.name}" .../>'
class TestCommand(TestObject):
def __init__(self, name, **kwargs):
super().__init__("Command", **kwargs)
self.attrs = {"name": name} | kwargs # name should be first
class TestScript(TestObject):
def __init__(self, script):
super().__init__("script")
self.script = script
self.children = [
NotStr(self.script),
]
@dataclass
class DoNotCheck:
desc: str = None
@dataclass
class Skip:
element: Any
desc: str = None
def _get_type(x):
if hasattr(x, "tag"):
return x.tag
if isinstance(x, TestObject):
if isinstance(x, (TestObject, TestIcon)):
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
return type(x).__name__
@@ -165,6 +267,34 @@ def _get_children(x):
return []
def _str_element(element, expected=None, keep_open=None):
# compare to itself if no expected element is provided
if expected is None:
expected = element
if hasattr(element, "tag"):
# the attributes are compared to the expected element
elt_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in
[attr_name for attr_name in _get_attributes(expected) if attr_name is not None]}
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
tag_str = f"({element.tag} {elt_attrs_str}"
# manage the closing tag
if keep_open is False:
tag_str += " ...)" if len(element.children) > 0 else ")"
elif keep_open is True:
tag_str += "..." if elt_attrs_str == "" else " ..."
else:
# close the tag if there are no children
not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
if len(not_special_children) == 0: tag_str += ")"
return tag_str
else:
return quoted_str(element)
class ErrorOutput:
def __init__(self, path, element, expected):
self.path = path
@@ -189,14 +319,14 @@ class ErrorOutput:
# first render the path hierarchy
for p in self.path.split(".")[:-1]:
elt_name, attr_name, attr_value = self._unconstruct_path_item(p)
path_str = self._str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
path_str = _str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
self._add_to_output(f"{path_str}")
self.indent += " "
# then render the element
if hasattr(self.expected, "tag") and hasattr(self.element, "tag"):
# display the tag and its attributes
tag_str = self._str_element(self.element, self.expected)
tag_str = _str_element(self.element, self.expected)
self._add_to_output(tag_str)
# Try to show where the differences are
@@ -219,7 +349,7 @@ class ErrorOutput:
# display the child
element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False)
child_str = _str_element(element_child, expected_child, keep_open=False)
self._add_to_output(child_str)
# manage errors (only when the expected is a FT element
@@ -253,34 +383,6 @@ class ErrorOutput:
def _add_to_output(self, msg):
self.output.append(f"{self.indent}{msg}")
@staticmethod
def _str_element(element, expected=None, keep_open=None):
# compare to itself if no expected element is provided
if expected is None:
expected = element
if hasattr(element, "tag"):
# the attributes are compared to the expected element
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in
[attr_name for attr_name in expected.attrs if attr_name is not None]}
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
tag_str = f"({element.tag} {elt_attrs_str}"
# manage the closing tag
if keep_open is False:
tag_str += " ...)" if len(element.children) > 0 else ")"
elif keep_open is True:
tag_str += "..." if elt_attrs_str == "" else " ..."
else:
# close the tag if there are no children
not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
if len(not_special_children) == 0: tag_str += ")"
return tag_str
else:
return quoted_str(element)
def _detect_error(self, element, expected):
"""
Detect errors between element and expected, returning a visual marker string.
@@ -291,21 +393,21 @@ class ErrorOutput:
element_type = _get_type(element)
expected_type = _get_type(expected)
type_error = (" " if element_type == expected_type else "^") * len(element_type)
element_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in _get_attributes(expected)}
expected_attrs = {attr_name: _get_attr(expected, attr_name) for attr_name in _get_attributes(expected)}
attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
not self._matches(attr_value, expected_attrs[attr_name])}
attrs_error = " ".join(
len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
for name, value in element_attrs.items()
)
if type_error.strip() or attrs_error.strip():
return f" {type_error} {attrs_error}"
return None
# For simple values
else:
if not self._matches(element, expected):
@@ -435,7 +537,7 @@ class Matcher:
# Validate the type/tag
assert _get_type(actual) == _get_type(expected), \
self._error_msg("The types are different.", _actual=_get_type(actual), _expected=_get_type(expected))
# Special conditions (ChildrenPredicate)
expected_children = _get_children(expected)
for predicate in [c for c in expected_children if isinstance(c, ChildrenPredicate)]:
@@ -443,25 +545,25 @@ class Matcher:
self._error_msg(f"The condition '{predicate}' is not satisfied.",
_actual=actual,
_expected=predicate.to_debug(expected))
# Compare the attributes
expected_attrs = _get_attributes(expected)
for expected_attr, expected_value in expected_attrs.items():
actual_value = _get_attr(actual, expected_attr)
# Check if attribute exists
if actual_value == MISSING_ATTR:
self._assert_error(f"'{expected_attr}' is not found in Actual.",
self._assert_error(f"'{expected_attr}' is not found in Actual. (attributes: {self._str_attrs(actual)})",
_actual=actual,
_expected=expected)
# Handle Predicate values
if isinstance(expected_value, Predicate):
assert expected_value.validate(actual_value), \
self._error_msg(f"The condition '{expected_value}' is not satisfied.",
_actual=actual,
_expected=expected)
# Handle TestObject recursive matching
elif isinstance(expected, TestObject):
try:
@@ -476,25 +578,46 @@ class Matcher:
self._assert_error(f"The values are different for '{expected_attr}'.",
_actual=actual_value,
_expected=expected_value)
# Handle regular value comparison
else:
assert actual_value == expected_value, \
self._error_msg(f"The values are different for '{expected_attr}'.",
_actual=actual,
_expected=expected)
# Compare the children (only if present)
if expected_children:
# Filter out Predicate children
expected_children = [c for c in expected_children if not isinstance(c, Predicate)]
actual_children = _get_children(actual)
if len(actual_children) < len(expected_children):
self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected)
for actual_child, expected_child in zip(actual_children, expected_children):
actual_child_index, expected_child_index = 0, 0
while expected_child_index < len(expected_children):
if actual_child_index >= len(actual_children):
self._assert_error("Nothing more to skip.", _actual=actual, _expected=expected)
actual_child = actual_children[actual_child_index]
expected_child = expected_children[expected_child_index]
if isinstance(expected_child, Skip):
try:
# if this is the element to skip, skip it and continue
self._match_element(actual_child, expected_child.element)
actual_child_index += 1
continue
except AssertionError:
# otherwise try to match with the following element
expected_child_index += 1
continue
assert self.matches(actual_child, expected_child)
actual_child_index += 1
expected_child_index += 1
def _match_list(self, actual, expected):
"""Match list or tuple."""
@@ -517,7 +640,7 @@ class Matcher:
def _match_notstr(self, actual, expected):
"""Match NotStr type."""
to_compare = actual.s.lstrip('\n').lstrip()
to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
assert to_compare.startswith(expected.s), self._error_msg("Notstr values are different: ",
_actual=to_compare,
_expected=expected.s)
@@ -567,14 +690,15 @@ class Matcher:
return elt.__class__.__name__
@staticmethod
def _str_attrs(attrs: dict):
def _str_attrs(element):
"""Format attributes as a string."""
attrs = _get_attributes(element)
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
@staticmethod
def _debug(elt):
"""Format an element for debug output."""
return str(elt) if elt else "None"
return _str_element(elt, keep_open=False) if elt else "None"
def matches(actual, expected, path=""):
@@ -610,75 +734,79 @@ def find(ft, expected):
Raises:
AssertionError: If no matching elements are found
"""
def _elements_match(actual, expected):
"""Check if two elements are the same based on tag, attributes, and children."""
# Quick equality check
if actual == expected:
if isinstance(expected, DoNotCheck):
return True
# Check if both are FT elements
if not (hasattr(actual, "tag") and hasattr(expected, "tag")):
return False
if isinstance(expected, NotStr):
to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
return to_compare.startswith(expected.s)
if isinstance(actual, NotStr) and _get_type(actual) != _get_type(expected):
return False # to manage the unexpected __eq__ behavior of NotStr
if not isinstance(expected, (TestObject, FT)):
return actual == expected
# Compare tags
if _get_type(actual) != _get_type(expected):
return False
# Compare attributes
expected_attrs = _get_attributes(expected)
actual_attrs = _get_attributes(actual)
for attr_name, attr_value in expected_attrs.items():
if attr_name not in actual_attrs or actual_attrs[attr_name] != attr_value:
for attr_name, expected_attr_value in expected_attrs.items():
actual_attr_value = _get_attr(actual, attr_name)
# attribute is missing
if actual_attr_value == MISSING_ATTR:
return False
# manage predicate values
if isinstance(expected_attr_value, Predicate):
return expected_attr_value.validate(actual_attr_value)
# finally compare values
return actual_attr_value == expected_attr_value
# Compare children recursively
expected_children = _get_children(expected)
actual_children = _get_children(actual)
for expected_child in expected_children:
# Check if this expected child exists somewhere in actual children
if not any(_elements_match(actual_child, expected_child) for actual_child in actual_children):
return False
return True
def _search_tree(current, pattern):
"""Recursively search for pattern in the tree rooted at current."""
# Type mismatch - can't be the same
if type(current) != type(pattern):
return []
# For non-FT elements, simple equality check
if not hasattr(current, "tag"):
return [current] if current == pattern else []
# Check if current element matches
matches = []
if _elements_match(current, pattern):
matches.append(current)
# Recursively search in children
# Recursively search in children, in the case that the pattern also appears in children
for child in _get_children(current):
matches.extend(_search_tree(child, pattern))
return matches
# Normalize input to list
elements_to_search = ft if isinstance(ft, (list, tuple, set)) else [ft]
# Search in all provided elements
all_matches = []
for element in elements_to_search:
all_matches.extend(_search_tree(element, expected))
# Raise error if nothing found
if not all_matches:
raise AssertionError(f"No element found for '{expected}'")
return all_matches
def find_one(ft, expected):
found = find(ft, expected)
assert len(found) == 1, f"Found {len(found)} elements for '{expected}'"
return found[0]
def _str_attrs(attrs: dict):
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())

View File

@@ -1,6 +1,6 @@
import pytest
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.instances import SingleInstance, InstancesManager
class RootInstanceForTests(SingleInstance):
@@ -25,4 +25,5 @@ def session():
@pytest.fixture(scope="session")
def root_instance(session):
InstancesManager.reset()
return RootInstanceForTests(session=session)

View File

@@ -45,7 +45,7 @@ def test_i_can_mk_button_with_attrs():
def test_i_can_mk_button_with_command(user, rt):
def new_value(value): return value
command = Command('test', 'TestingCommand', new_value, "this is my new value")
command = Command('test', 'TestingCommand', None, new_value, "this is my new value")
@rt('/')
def get(): return mk.button('button', command)

View File

@@ -4,9 +4,10 @@ import shutil
import pytest
from fasthtml.components import *
from myfasthtml.controls.Layout import Layout, LayoutState
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.UserProfile import UserProfile
from myfasthtml.test.matcher import matches, find, Contains, NotStr, TestObject
from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript, TestObject, AnyValue, Skip, \
TestIconNotStr
from .conftest import root_instance
@@ -17,219 +18,210 @@ def cleanup_db():
class TestLayoutBehaviour:
"""Tests for Layout behavior and logic."""
def test_i_can_create_layout(self, root_instance):
"""Test basic layout creation with app_name."""
layout = Layout(root_instance, app_name="Test App")
assert layout is not None
assert layout.app_name == "Test App"
assert layout._state is not None
def test_i_can_set_main_content(self, root_instance):
"""Test setting main content area."""
layout = Layout(root_instance, app_name="Test App")
content = Div("Main content")
result = layout.set_main(content)
assert layout._main_content == content
assert result == layout # Should return self for chaining
def test_i_can_set_footer_content(self, root_instance):
"""Test setting footer content."""
layout = Layout(root_instance, app_name="Test App")
content = Div("Footer content")
layout.set_footer(content)
assert layout._footer_content == content
def test_i_can_add_content_to_left_drawer(self, root_instance):
"""Test adding content to left drawer."""
layout = Layout(root_instance, app_name="Test App")
content = Div("Left drawer content", id="drawer_item")
layout.left_drawer.add(content)
drawer_content = layout.left_drawer.get_content()
assert None in drawer_content
assert content in drawer_content[None]
def test_i_can_add_content_to_right_drawer(self, root_instance):
"""Test adding content to right drawer."""
layout = Layout(root_instance, app_name="Test App")
content = Div("Right drawer content", id="drawer_item")
layout.right_drawer.add(content)
drawer_content = layout.right_drawer.get_content()
assert None in drawer_content
assert content in drawer_content[None]
def test_i_can_add_content_to_header_left(self, root_instance):
"""Test adding content to left side of header."""
layout = Layout(root_instance, app_name="Test App")
content = Div("Header left content", id="header_item")
layout.header_left.add(content)
header_content = layout.header_left.get_content()
assert None in header_content
assert content in header_content[None]
def test_i_can_add_content_to_header_right(self, root_instance):
"""Test adding content to right side of header."""
layout = Layout(root_instance, app_name="Test App")
content = Div("Header right content", id="header_item")
layout.header_right.add(content)
header_content = layout.header_right.get_content()
assert None in header_content
assert content in header_content[None]
def test_i_can_add_grouped_content(self, root_instance):
"""Test adding content with custom groups."""
layout = Layout(root_instance, app_name="Test App")
group_name = "navigation"
content1 = Div("Nav item 1", id="nav1")
content2 = Div("Nav item 2", id="nav2")
layout.left_drawer.add(content1, group=group_name)
layout.left_drawer.add(content2, group=group_name)
drawer_content = layout.left_drawer.get_content()
assert group_name in drawer_content
assert content1 in drawer_content[group_name]
assert content2 in drawer_content[group_name]
def test_i_cannot_add_duplicate_content(self, root_instance):
"""Test that content with same ID is not added twice."""
layout = Layout(root_instance, app_name="Test App")
content = Div("Content", id="unique_id")
layout.left_drawer.add(content)
layout.left_drawer.add(content) # Try to add again
drawer_content = layout.left_drawer.get_content()
# Content should appear only once
assert drawer_content[None].count(content) == 1
def test_i_can_toggle_left_drawer(self, root_instance):
"""Test toggling left drawer open/closed."""
layout = Layout(root_instance, app_name="Test App")
# Initially open
assert layout._state.left_drawer_open is True
# Toggle to close
layout.toggle_drawer("left")
assert layout._state.left_drawer_open is False
# Toggle to open
layout.toggle_drawer("left")
assert layout._state.left_drawer_open is True
def test_i_can_toggle_right_drawer(self, root_instance):
"""Test toggling right drawer open/closed."""
layout = Layout(root_instance, app_name="Test App")
# Initially open
assert layout._state.right_drawer_open is True
# Toggle to close
layout.toggle_drawer("right")
assert layout._state.right_drawer_open is False
# Toggle to open
layout.toggle_drawer("right")
assert layout._state.right_drawer_open is True
def test_i_can_update_left_drawer_width(self, root_instance):
"""Test updating left drawer width."""
layout = Layout(root_instance, app_name="Test App")
new_width = 300
layout.update_drawer_width("left", new_width)
assert layout._state.left_drawer_width == new_width
def test_i_can_update_right_drawer_width(self, root_instance):
"""Test updating right drawer width."""
layout = Layout(root_instance, app_name="Test App")
new_width = 400
layout.update_drawer_width("right", new_width)
assert layout._state.right_drawer_width == new_width
def test_i_cannot_set_drawer_width_below_minimum(self, root_instance):
"""Test that drawer width is constrained to minimum 150px."""
layout = Layout(root_instance, app_name="Test App")
layout.update_drawer_width("left", 100) # Try to set below minimum
assert layout._state.left_drawer_width == 150 # Should be clamped to min
def test_i_cannot_set_drawer_width_above_maximum(self, root_instance):
"""Test that drawer width is constrained to maximum 600px."""
layout = Layout(root_instance, app_name="Test App")
layout.update_drawer_width("right", 800) # Try to set above maximum
assert layout._state.right_drawer_width == 600 # Should be clamped to max
def test_i_cannot_toggle_invalid_drawer_side(self, root_instance):
"""Test that toggling invalid drawer side raises ValueError."""
layout = Layout(root_instance, app_name="Test App")
with pytest.raises(ValueError, match="Invalid drawer side"):
layout.toggle_drawer("invalid")
def test_i_cannot_update_invalid_drawer_width(self, root_instance):
"""Test that updating invalid drawer side raises ValueError."""
layout = Layout(root_instance, app_name="Test App")
with pytest.raises(ValueError, match="Invalid drawer side"):
layout.update_drawer_width("invalid", 250)
def test_layout_state_has_correct_defaults(self, root_instance):
"""Test that LayoutState initializes with correct default values."""
layout = Layout(root_instance, app_name="Test App")
state = layout._state
assert state.left_drawer_open is True
assert state.right_drawer_open is True
assert state.left_drawer_width == 250
assert state.right_drawer_width == 250
def test_layout_is_single_instance(self, root_instance):
"""Test that Layout behaves as SingleInstance (same ID returns same instance)."""
layout1 = Layout(root_instance, app_name="Test App", _id="my_layout")
layout2 = Layout(root_instance, app_name="Test App", _id="my_layout")
# Should be the same instance
assert layout1 is layout2
def test_commands_are_created(self, root_instance):
"""Test that Layout creates necessary commands."""
layout = Layout(root_instance, app_name="Test App")
# Test toggle commands
left_toggle_cmd = layout.commands.toggle_drawer("left")
assert left_toggle_cmd is not None
assert left_toggle_cmd.id is not None
right_toggle_cmd = layout.commands.toggle_drawer("right")
assert right_toggle_cmd is not None
assert right_toggle_cmd.id is not None
# Test width update commands
left_width_cmd = layout.commands.update_drawer_width("left")
assert left_width_cmd is not None
assert left_width_cmd.id is not None
right_width_cmd = layout.commands.update_drawer_width("right")
assert right_width_cmd is not None
assert right_width_cmd.id is not None
@@ -237,110 +229,111 @@ class TestLayoutBehaviour:
class TestLayoutRender:
"""Tests for Layout HTML rendering."""
def test_empty_layout_is_rendered(self, root_instance):
@pytest.fixture
def layout(self, root_instance):
return Layout(root_instance, app_name="Test App")
def test_empty_layout_is_rendered(self, layout):
"""Test that Layout renders with all main structural sections.
Why these elements matter:
- 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script)
- 7 children: Verifies all main sections are rendered (tooltip container, header, drawers, main, footer, script)
- _id: Essential for layout identification and resizer initialization
- cls="mf-layout": Root CSS class for layout styling
"""
layout = Layout(root_instance, app_name="Test App")
expected = Div(
Header(),
Div(),
Main(),
Div(),
Footer(),
Script(),
_id=layout._id,
cls="mf-layout"
Div(), # tooltip container
Header(),
Div(), # left drawer
Main(),
Div(), # right drawer
Footer(),
Script(),
_id=layout._id,
cls="mf-layout"
)
assert matches(layout.render(), expected)
def test_header_with_drawer_icons_is_rendered(self, root_instance):
def test_header_has_two_sides(self, layout):
"""Test that there is a left and right header section."""
header = find_one(layout.render(), Header(cls="mf-layout-header"))
expected = Header(
Div(id=f"{layout._id}_hl"),
Div(id=f"{layout._id}_hr"),
)
assert matches(header, expected)
def test_footer_has_two_sides(self, layout):
"""Test that there is a left and right footer section."""
footer = find_one(layout.render(), Footer(cls=Contains("mf-layout-footer")))
expected = Footer(
Div(id=f"{layout._id}_fl"),
Div(id=f"{layout._id}_fr"),
)
assert matches(footer, expected)
def test_header_with_drawer_icons_is_rendered(self, layout):
"""Test that header renders with drawer toggle icons.
Why these elements matter:
- 2 Div children: Left/right header structure for organizing controls
- Svg: Toggle icon is essential for user interaction with drawer
- TestObject(UserProfile): UserProfile component must be present in header
- cls="flex gap-1": CSS critical for horizontal alignment of header items
- cls="mf-layout-header": Root header class for styling
- Only the first div is required to test the presence of the icon
- Use TestIcon to test the existence of an icon
"""
layout = Layout(root_instance, app_name="Test App")
header = layout._mk_header()
header = find_one(layout.render(), Header(cls="mf-layout-header"))
expected = Header(
Div(
Div(NotStr(name="panel_right_expand20_regular")),
cls="flex gap-1"
),
Div(
TestObject(UserProfile),
cls="flex gap-1"
),
cls="mf-layout-header"
Div(
TestIcon("PanelLeftContract20Regular"),
cls="flex gap-1"
),
cls="mf-layout-header"
)
assert matches(header, expected)
def test_footer_is_rendered(self, root_instance):
"""Test that footer renders with correct structure.
Why these elements matter:
- cls Contains "mf-layout-footer": Root footer class for styling
- cls Contains "footer": DaisyUI base footer class
"""
layout = Layout(root_instance, app_name="Test App")
footer = layout._mk_footer()
expected = Footer(
cls=Contains("mf-layout-footer")
)
assert matches(footer, expected)
def test_main_content_is_rendered(self, root_instance):
def test_main_content_is_rendered_with_some_element(self, layout):
"""Test that main content area renders correctly.
Why these elements matter:
- cls="mf-layout-main": Root main class for styling
"""
layout = Layout(root_instance, app_name="Test App")
main = layout._mk_main()
layout.set_main(Div("Main content"))
main = find_one(layout.render(), Main(cls="mf-layout-main"))
expected = Main(
cls="mf-layout-main"
Div("Main content"),
cls="mf-layout-main"
)
assert matches(main, expected)
def test_left_drawer_is_rendered_when_open(self, root_instance):
def test_left_drawer_is_rendered_when_open(self, layout):
"""Test that left drawer renders with correct classes when open.
Why these elements matter:
- _id: Required for targeting drawer in HTMX updates
- _id: Required for targeting drawer in HTMX updates. search by id whenever possible
- cls Contains "mf-layout-drawer": Base drawer class for styling
- cls Contains "mf-layout-left-drawer": Left-specific drawer positioning
- style Contains width: Drawer width must be applied for sizing
"""
layout = Layout(root_instance, app_name="Test App")
drawer = layout._mk_left_drawer()
layout._state.left_drawer_open = True
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
expected = Div(
_id=f"{layout._id}_ld",
cls=Contains("mf-layout-drawer"),
#cls=Contains("mf-layout-left-drawer"),
style=Contains("width: 250px")
_id=f"{layout._id}_ld",
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"),
style=Contains("width: 250px")
)
assert matches(drawer, expected)
def test_left_drawer_has_collapsed_class_when_closed(self, root_instance):
def test_left_drawer_has_collapsed_class_when_closed(self, layout):
"""Test that left drawer renders with collapsed class when closed.
Why these elements matter:
@@ -348,19 +341,18 @@ class TestLayoutRender:
- cls Contains "collapsed": Class triggers CSS hiding animation
- style Contains "width: 0px": Zero width is crucial for collapse animation
"""
layout = Layout(root_instance, app_name="Test App")
layout._state.left_drawer_open = False
drawer = layout._mk_left_drawer()
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
expected = Div(
_id=f"{layout._id}_ld",
cls=Contains("collapsed"),
style=Contains("width: 0px")
_id=f"{layout._id}_ld",
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer", "collapsed"),
style=Contains("width: 0px")
)
assert matches(drawer, expected)
def test_right_drawer_is_rendered_when_open(self, root_instance):
def test_right_drawer_is_rendered_when_open(self, layout):
"""Test that right drawer renders with correct classes when open.
Why these elements matter:
@@ -369,19 +361,18 @@ class TestLayoutRender:
- cls Contains "mf-layout-right-drawer": Right-specific drawer positioning
- style Contains width: Drawer width must be applied for sizing
"""
layout = Layout(root_instance, app_name="Test App")
drawer = layout._mk_right_drawer()
layout._state.right_drawer_open = True
drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
expected = Div(
_id=f"{layout._id}_rd",
cls=Contains("mf-layout-drawer"),
#cls=Contains("mf-layout-right-drawer"),
style=Contains("width: 250px")
_id=f"{layout._id}_rd",
cls=Contains("mf-layout-drawer", "mf-layout-right-drawer"),
style=Contains("width: 250px")
)
assert matches(drawer, expected)
def test_right_drawer_has_collapsed_class_when_closed(self, root_instance):
def test_right_drawer_has_collapsed_class_when_closed(self, layout):
"""Test that right drawer renders with collapsed class when closed.
Why these elements matter:
@@ -389,94 +380,298 @@ class TestLayoutRender:
- cls Contains "collapsed": Class triggers CSS hiding animation
- style Contains "width: 0px": Zero width is crucial for collapse animation
"""
layout = Layout(root_instance, app_name="Test App")
layout._state.right_drawer_open = False
drawer = layout._mk_right_drawer()
drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
expected = Div(
_id=f"{layout._id}_rd",
cls=Contains("collapsed"),
style=Contains("width: 0px")
_id=f"{layout._id}_rd",
cls=Contains("mf-layout-drawer", "mf-layout-right-drawer", "collapsed"),
style=Contains("width: 0px")
)
assert matches(drawer, expected)
def test_drawer_width_is_applied_as_style(self, root_instance):
def test_drawer_width_is_applied_as_style(self, layout):
"""Test that custom drawer width is applied as inline style.
Why this test matters:
- style Contains "width: 300px": Verifies that width updates are reflected in style attribute
"""
layout = Layout(root_instance, app_name="Test App")
layout._state.left_drawer_width = 300
drawer = layout._mk_left_drawer()
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
expected = Div(
style=Contains("width: 300px")
style=Contains("width: 300px")
)
assert matches(drawer, expected)
def test_left_drawer_has_resizer_element(self, root_instance):
def test_left_drawer_has_resizer_element(self, layout):
"""Test that left drawer contains resizer element.
Why this test matters:
- Resizer element must be present for drawer width adjustment
- cls "mf-resizer-left": Left-specific resizer for correct edge positioning
"""
layout = Layout(root_instance, app_name="Test App")
drawer = layout._mk_left_drawer()
drawer = find(layout.render(), Div(id=f"{layout._id}_ld"))
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
def test_right_drawer_has_resizer_element(self, root_instance):
def test_right_drawer_has_resizer_element(self, layout):
"""Test that right drawer contains resizer element.
Why this test matters:
- Resizer element must be present for drawer width adjustment
- cls "mf-resizer-right": Right-specific resizer for correct edge positioning
"""
layout = Layout(root_instance, app_name="Test App")
drawer = layout._mk_right_drawer()
drawer = find(layout.render(), Div(id=f"{layout._id}_rd"))
resizers = find(drawer, Div(cls=Contains("mf-resizer-right")))
assert len(resizers) == 1, "Right drawer should contain exactly one resizer element"
def test_drawer_groups_are_separated_by_dividers(self, root_instance):
"""Test that multiple groups in drawer are separated by divider elements.
Why this test matters:
- Dividers provide visual separation between content groups
- At least one divider should exist when multiple groups are present
"""
layout = Layout(root_instance, app_name="Test App")
layout.left_drawer.add(Div("Item 1"), group="group1")
layout.left_drawer.add(Div("Item 2"), group="group2")
drawer = layout._mk_left_drawer()
content_wrappers = find(drawer, Div(cls="mf-layout-drawer-content"))
assert len(content_wrappers) == 1
content = content_wrappers[0]
dividers = find(content, Div(cls="divider"))
assert len(dividers) >= 1, "Groups should be separated by dividers"
def test_resizer_script_is_included(self, root_instance):
def test_resizer_script_is_included(self, layout):
"""Test that resizer initialization script is included in render.
Why this test matters:
- Script element: Required to initialize resizer functionality
- Script contains initResizer call: Ensures resizer is activated for this layout instance
- Script contains initLayout call: Ensures layout is activated for this layout instance
"""
layout = Layout(root_instance, app_name="Test App")
rendered = layout.render()
script = find_one(layout.render(), Script())
expected = TestScript(f"initLayout('{layout._id}');")
assert matches(script, expected)
def test_left_drawer_renders_content_with_groups(self, layout):
"""Test that left drawer renders content organized by groups with proper wrappers.
scripts = find(rendered, Script())
assert len(scripts) == 1, "Layout should contain exactly one script element"
Why these elements matter:
- mf-layout-drawer-content wrapper: Required container for drawer scrolling behavior
- divider elements: Visual separation between content groups
- Group count validation: Ensures all added groups are rendered
"""
layout.left_drawer.add(Div("Item 1", id="item1"), group="group1")
layout.left_drawer.add(Div("Item 2", id="item2"), group="group2")
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
content_wrappers = find(drawer, Div(cls="mf-layout-drawer-content"))
assert len(content_wrappers) == 1, "Left drawer should contain exactly one content wrapper"
content = content_wrappers[0]
dividers = find(content, Div(cls="divider"))
assert len(dividers) == 1, "Two groups should be separated by exactly one divider"
def test_header_left_renders_custom_content(self, layout):
"""Test that custom content added to header_left is rendered in the left header section.
script_content = str(scripts[0].children[0])
assert f"initResizer('{layout._id}')" in script_content, "Script must initialize resizer with layout ID"
Why these elements matter:
- id="{layout._id}_hl": Essential for HTMX targeting during updates
- cls Contains "flex": Ensures horizontal layout of header items
- Icon presence: Toggle drawer icon must always be first element
- Custom content: Verifies header_left.add() correctly renders content
"""
custom_content = Div("Custom Header", id="custom_header")
layout.header_left.add(custom_content)
header_left = find_one(layout.render(), Div(id=f"{layout._id}_hl"))
expected = Div(
TestIcon(""),
Skip(None),
Div("Custom Header", id="custom_header"),
id=f"{layout._id}_hl",
cls=Contains("flex", "gap-1")
)
assert matches(header_left, expected)
def test_header_right_renders_custom_content(self, layout):
"""Test that custom content added to header_right is rendered in the right header section.
Why these elements matter:
- id="{layout._id}_hr": Essential for HTMX targeting during updates
- cls Contains "flex": Ensures horizontal layout of header items
- Custom content: Verifies header_right.add() correctly renders content
- UserProfile component: Must always be last element in right header
"""
custom_content = Div("Custom Header Right", id="custom_header_right")
layout.header_right.add(custom_content)
header_right = find_one(layout.render(), Div(id=f"{layout._id}_hr"))
expected = Div(
Skip(None),
Div("Custom Header Right", id="custom_header_right"),
TestObject(UserProfile),
id=f"{layout._id}_hr",
cls=Contains("flex", "gap-1")
)
assert matches(header_right, expected)
def test_footer_left_renders_custom_content(self, layout):
"""Test that custom content added to footer_left is rendered in the left footer section.
Why these elements matter:
- id="{layout._id}_fl": Essential for HTMX targeting during updates
- cls Contains "flex": Ensures horizontal layout of footer items
- Custom content: Verifies footer_left.add() correctly renders content
"""
custom_content = Div("Custom Footer Left", id="custom_footer_left")
layout.footer_left.add(custom_content)
footer_left = find_one(layout.render(), Div(id=f"{layout._id}_fl"))
expected = Div(
Skip(None),
Div("Custom Footer Left", id="custom_footer_left"),
id=f"{layout._id}_fl",
cls=Contains("flex", "gap-1")
)
assert matches(footer_left, expected)
def test_footer_right_renders_custom_content(self, layout):
"""Test that custom content added to footer_right is rendered in the right footer section.
Why these elements matter:
- id="{layout._id}_fr": Essential for HTMX targeting during updates
- cls Contains "flex": Ensures horizontal layout of footer items
- Custom content: Verifies footer_right.add() correctly renders content
"""
custom_content = Div("Custom Footer Right", id="custom_footer_right")
layout.footer_right.add(custom_content)
footer_right = find_one(layout.render(), Div(id=f"{layout._id}_fr"))
expected = Div(
Skip(None),
Div("Custom Footer Right", id="custom_footer_right"),
id=f"{layout._id}_fr",
cls=Contains("flex", "gap-1")
)
assert matches(footer_right, expected)
def test_left_drawer_resizer_has_command_data(self, layout):
"""Test that left drawer resizer has correct data attributes for command binding.
Why these elements matter:
- data_command_id: JavaScript uses this to trigger width update command
- data_side="left": JavaScript needs this to identify which drawer to resize
- cls Contains "mf-resizer-left": CSS uses this for left-specific positioning
"""
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
resizer = find_one(drawer, Div(cls=Contains("mf-resizer-left")))
expected = Div(
cls=Contains("mf-resizer", "mf-resizer-left"),
data_command_id=AnyValue(),
data_side="left"
)
assert matches(resizer, expected)
def test_right_drawer_resizer_has_command_data(self, layout):
"""Test that right drawer resizer has correct data attributes for command binding.
Why these elements matter:
- data_command_id: JavaScript uses this to trigger width update command
- data_side="right": JavaScript needs this to identify which drawer to resize
- cls Contains "mf-resizer-right": CSS uses this for right-specific positioning
"""
drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
resizer = find_one(drawer, Div(cls=Contains("mf-resizer-right")))
expected = Div(
cls=Contains("mf-resizer", "mf-resizer-right"),
data_command_id=AnyValue(),
data_side="right"
)
assert matches(resizer, expected)
def test_left_drawer_icon_changes_when_closed(self, layout):
"""Test that left drawer toggle icon changes from expand to collapse when drawer is closed.
Why these elements matter:
- id="{layout._id}_ldi": Required for HTMX swap-oob updates when toggling
- Icon type: Visual feedback to user about drawer state (expand icon when closed)
- Icon change: Validates that toggle_drawer returns correct icon
"""
layout._state.left_drawer_open = False
icon_div = find_one(layout.render(), Div(id=f"{layout._id}_ldi"))
expected = Div(
TestIconNotStr("panel_left_expand20_regular"),
id=f"{layout._id}_ldi"
)
assert matches(icon_div, expected)
def test_left_drawer_icon_changes_when_opne(self, layout):
"""Test that left drawer toggle icon changes from collapse to expand when drawer is open..
Why these elements matter:
- id="{layout._id}_ldi": Required for HTMX swap-oob updates when toggling
- Icon type: Visual feedback to user about drawer state (expand icon when closed)
- Icon change: Validates that toggle_drawer returns correct icon
"""
layout._state.left_drawer_open = True
icon_div = find_one(layout.render(), Div(id=f"{layout._id}_ldi"))
expected = Div(
TestIconNotStr("panel_left_contract20_regular"),
id=f"{layout._id}_ldi"
)
assert matches(icon_div, expected)
def test_tooltip_container_is_rendered(self, layout):
"""Test that tooltip container is rendered at the top of the layout.
Why these elements matter:
- id="tt_{layout._id}": JavaScript uses this to append dynamically created tooltips
- cls Contains "mf-tooltip-container": CSS positioning for tooltip overlay layer
- Presence verification: Tooltips won't work if container is missing
"""
tooltip_container = find_one(layout.render(), Div(id=f"tt_{layout._id}"))
expected = Div(
id=f"tt_{layout._id}",
cls=Contains("mf-tooltip-container")
)
assert matches(tooltip_container, expected)
def test_header_right_contains_user_profile(self, layout):
"""Test that UserProfile component is rendered in the right header section.
Why these elements matter:
- UserProfile component: Provides authentication and user menu functionality
- Position in header right: Conventional placement for user profile controls
- Count verification: Ensures component is not duplicated
"""
header_right = find_one(layout.render(), Div(id=f"{layout._id}_hr"))
user_profiles = find(header_right, TestObject(UserProfile))
assert len(user_profiles) == 1, "Header right should contain exactly one UserProfile component"
def test_layout_initialization_script_is_included(self, layout):
"""Test that layout initialization script is included in render output.
Why these elements matter:
- Script presence: Required to initialize layout behavior (resizers, drawers)
- initLayout() call: Activates JavaScript functionality for this layout instance
- Layout ID parameter: Ensures initialization targets correct layout
"""
script = find_one(layout.render(), Script())
expected = TestScript(f"initLayout('{layout._id}');")
assert matches(script, expected)

View File

@@ -1,17 +1,18 @@
import shutil
import pytest
from fasthtml.components import *
from fasthtml.xtend import Script
from fasthtml.common import Div, Span
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.VisNetwork import VisNetwork
from myfasthtml.core.instances import InstancesManager
from myfasthtml.test.matcher import matches, NoChildren
from .conftest import session
from myfasthtml.test.matcher import matches, find_one, find, Contains, TestIcon, TestScript, TestObject, DoesNotContain, \
And, TestIconNotStr
@pytest.fixture()
def tabs_manager(root_instance):
"""Create a fresh TabsManager instance for each test."""
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
yield TabsManager(root_instance)
@@ -20,107 +21,799 @@ def tabs_manager(root_instance):
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
"""Tests for TabsManager behavior and logic."""
def test_i_can_add_tab(self, tabs_manager):
tab_id = tabs_manager.add_tab("Tab1", Div("Content 1"))
# =========================================================================
# Initialization
# =========================================================================
def test_i_can_create_tabs_manager(self, root_instance):
"""Test that TabsManager can be created with default state."""
tm = TabsManager(root_instance)
assert tm is not None
assert tm.get_state().tabs == {}
assert tm.get_state().tabs_order == []
assert tm.get_state().active_tab is None
assert tm.get_state().ns_tabs_content == {}
assert tm.get_state().ns_tabs_sent_to_client == set()
assert tm._tab_count == 0
# =========================================================================
# Tab Creation
# =========================================================================
def test_i_can_create_tab_with_simple_component(self, tabs_manager):
"""Test creating a tab with a simple Div component."""
tab_id = tabs_manager.create_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[tab_id]["component"] is None
assert tabs_manager.get_state().tabs[tab_id]["component_parent"] is None
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"))
def test_i_can_create_tab_with_base_instance(self, tabs_manager):
"""Test creating a tab with a BaseInstance component (VisNetwork)."""
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
tab_id = tabs_manager.create_tab("Network", vis_network)
component_type, component_id = vis_network.get_prefix(), vis_network.get_id()
parent_type, parent_id = tabs_manager.get_prefix(), tabs_manager.get_id()
assert tab_id is not None
assert tabs_manager.get_state().tabs[tab_id]["component"] == (component_type, component_id)
assert tabs_manager.get_state().tabs[tab_id]["component_parent"] == (parent_type, parent_id)
def test_i_can_create_multiple_tabs(self, tabs_manager):
"""Test creating multiple tabs maintains correct order and activation."""
tab_id1 = tabs_manager.create_tab("Users", Div("Content 1"))
tab_id2 = tabs_manager.create_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"))
def test_created_tab_is_activated_by_default(self, tabs_manager):
"""Test that newly created tab becomes active by default."""
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
assert tabs_manager.get_state().active_tab == tab_id2 # last crated tab is active
assert tabs_manager.get_state().active_tab == tab_id
def test_i_can_create_tab_without_activating(self, tabs_manager):
"""Test creating a tab without activating it."""
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"), activate=False)
assert tab_id2 in tabs_manager.get_state().tabs
assert tabs_manager.get_state().active_tab == tab_id1
# =========================================================================
# Tab Activation
# =========================================================================
def test_i_can_show_existing_tab(self, tabs_manager):
"""Test showing an existing tab activates it."""
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
assert tabs_manager.get_state().active_tab == tab_id2
tabs_manager.show_tab(tab_id1)
assert tabs_manager.get_state().active_tab == tab_id1
def test_i_can_show_tab_without_activating(self, tabs_manager):
"""Test showing a tab without changing active tab."""
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
assert tabs_manager.get_state().active_tab == tab_id2
tabs_manager.show_tab(tab_id1, activate=False)
assert tabs_manager.get_state().active_tab == tab_id2
def test_show_tab_returns_controller_only_when_already_sent(self, tabs_manager):
"""Test that show_tab returns only controller when tab was already sent to client."""
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
# First call: tab not sent yet, returns controller + header + content
result1 = tabs_manager.show_tab(tab_id, oob=False)
assert len(result1) == 3 # controller, header, wrapped_content
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
# Second call: tab already sent, returns only controller
result2 = tabs_manager.show_tab(tab_id, oob=False)
assert result2 is not None
# Result2 is a single Div (controller), not a tuple
assert hasattr(result2, 'tag') and result2.tag == 'div'
def test_i_cannot_show_nonexistent_tab(self, tabs_manager):
"""Test that showing a nonexistent tab returns None."""
result = tabs_manager.show_tab("nonexistent_id")
assert result is None
# =========================================================================
# Tab Closing
# =========================================================================
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"))
"""Test closing a tab removes it from state."""
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
tab_id3 = tabs_manager.create_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 tab_id2 not in tabs_manager.get_state().tabs
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id3]
assert tabs_manager.get_state().active_tab == tab_id3 # last tab stays active
assert tabs_manager.get_state().active_tab == tab_id3
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"))
def test_closing_active_tab_activates_first_remaining(self, tabs_manager):
"""Test that closing the active tab activates the first remaining tab."""
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
tab_id3 = tabs_manager.create_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
assert tabs_manager.get_state().active_tab == tab_id1
def test_closing_last_tab_sets_active_to_none(self, tabs_manager):
"""Test that closing the last remaining tab sets active_tab to None."""
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
tabs_manager.close_tab(tab_id)
assert len(tabs_manager.get_state().tabs) == 0
assert tabs_manager.get_state().active_tab is None
def test_close_tab_cleans_cache(self, tabs_manager):
"""Test that closing a tab removes it from content cache."""
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
# Trigger cache population
tabs_manager._get_or_create_tab_content(tab_id)
assert tab_id in tabs_manager.get_state().ns_tabs_content
tabs_manager.close_tab(tab_id)
assert tab_id not in tabs_manager.get_state().ns_tabs_content
def test_close_tab_cleans_sent_to_client(self, tabs_manager):
"""Test that closing a tab removes it from ns_tabs_sent_to_client."""
tab_id = tabs_manager.create_tab("Tab1", Div("Content 1"))
# Send tab to client
tabs_manager.show_tab(tab_id)
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
tabs_manager.close_tab(tab_id)
assert tab_id not in tabs_manager.get_state().ns_tabs_sent_to_client
def test_i_cannot_close_nonexistent_tab(self, tabs_manager):
"""Test that closing a nonexistent tab returns self without error."""
result = tabs_manager.close_tab("nonexistent_id")
assert result == tabs_manager
# =========================================================================
# Content Management
# =========================================================================
def test_cached_content_is_reused(self, tabs_manager):
"""Test that cached content is returned without re-creation."""
content = Div("Test Content")
tab_id = tabs_manager.create_tab("Tab1", content)
# First call creates cache
result1 = tabs_manager._get_or_create_tab_content(tab_id)
# Second call should return same object from cache
result2 = tabs_manager._get_or_create_tab_content(tab_id)
assert result1 is result2
def test_dynamic_get_content_for_base_instance(self, tabs_manager):
"""Test that _dynamic_get_content retrieves BaseInstance from InstancesManager."""
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
tab_id = tabs_manager.create_tab("Network", vis_network)
# Get content dynamically
result = tabs_manager._dynamic_get_content(tab_id)
assert result == vis_network
def test_dynamic_get_content_for_simple_component(self, tabs_manager):
"""Test that _dynamic_get_content returns error message for non-BaseInstance."""
content = Div("Simple content")
tab_id = tabs_manager.create_tab("Tab1", content)
# Get content dynamically (should fail gracefully)
result = tabs_manager._dynamic_get_content(tab_id)
# Should return error Div since Div is not serializable
assert matches(result, Div('Tab content does not support serialization.'))
def test_dynamic_get_content_for_nonexistent_tab(self, tabs_manager):
"""Test that _dynamic_get_content returns error for nonexistent tab."""
result = tabs_manager._dynamic_get_content("nonexistent_id")
assert matches(result, Div('Tab not found.'))
# =========================================================================
# Content Change
# =========================================================================
def test_i_can_change_tab_content(self, tabs_manager):
"""Test changing the content of an existing tab."""
tab_id = tabs_manager.create_tab("Tab1", Div("Original Content"))
new_content = Div("New Content")
tabs_manager.change_tab_content(tab_id, "Updated Tab", new_content)
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Updated Tab"
assert tabs_manager.get_state().ns_tabs_content[tab_id] == new_content
def test_change_tab_content_invalidates_cache(self, tabs_manager):
"""Test that changing tab content invalidates the sent_to_client cache."""
tab_id = tabs_manager.create_tab("Tab1", Div("Original"))
# Send tab to client
tabs_manager.show_tab(tab_id)
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
# Change content
tabs_manager.change_tab_content(tab_id, "Tab1", Div("New Content"))
# Cache should be invalidated (discard was called)
# The show_tab inside change_tab_content will re-add it, but the test
# verifies that discard was called by checking the behavior
# Since show_tab is called after discard, tab will be in sent_to_client again
# We verify the invalidation worked by checking the method was called
# This is more of an integration test
assert tab_id in tabs_manager.get_state().ns_tabs_sent_to_client
def test_i_cannot_change_content_of_nonexistent_tab(self, tabs_manager):
"""Test that changing content of nonexistent tab returns None."""
result = tabs_manager.change_tab_content("nonexistent", "Label", Div("Content"))
assert result is None
# =========================================================================
# Auto-increment
# =========================================================================
def test_on_new_tab_with_auto_increment(self, tabs_manager):
"""Test that on_new_tab with auto_increment appends counter to label."""
tabs_manager.on_new_tab("Untitled", None, auto_increment=True)
tabs_manager.on_new_tab("Untitled", None, auto_increment=True)
tabs_manager.on_new_tab("Untitled", None, auto_increment=True)
tabs = list(tabs_manager.get_state().tabs.values())
assert tabs[0]["label"] == "Untitled_0"
assert tabs[1]["label"] == "Untitled_1"
assert tabs[2]["label"] == "Untitled_2"
def test_each_instance_has_independent_counter(self, root_instance):
"""Test that each TabsManager instance has its own independent counter."""
tm1 = TabsManager(root_instance, _id="tm1")
tm2 = TabsManager(root_instance, _id="tm2")
tm1.on_new_tab("Tab", None, auto_increment=True)
tm1.on_new_tab("Tab", None, auto_increment=True)
tm2.on_new_tab("Tab", None, auto_increment=True)
# tm1 should have Tab_0 and Tab_1
tabs_tm1 = list(tm1.get_state().tabs.values())
assert tabs_tm1[0]["label"] == "Tab_0"
assert tabs_tm1[1]["label"] == "Tab_1"
# tm2 should have Tab_0 (independent counter)
tabs_tm2 = list(tm2.get_state().tabs.values())
assert tabs_tm2[0]["label"] == "Tab_0"
def test_auto_increment_uses_default_component_when_none(self, tabs_manager):
"""Test that on_new_tab uses VisNetwork when component is None."""
tabs_manager.on_new_tab("Network", None, auto_increment=False)
tab_id = tabs_manager.get_state().tabs_order[0]
content = tabs_manager.get_state().ns_tabs_content[tab_id]
assert isinstance(content, VisNetwork)
# =========================================================================
# Duplicate Detection
# =========================================================================
def test_tab_already_exists_with_same_label_and_component(self, tabs_manager):
"""Test that duplicate tab is detected and returns existing tab_id."""
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
tab_id1 = tabs_manager.create_tab("Network", vis_network)
tab_id2 = tabs_manager.create_tab("Network", vis_network)
# Should return same tab_id
assert tab_id1 == tab_id2
assert len(tabs_manager.get_state().tabs) == 1
def test_tab_already_exists_returns_none_for_different_label(self, tabs_manager):
"""Test that same component with different label creates new tab."""
vis_network = VisNetwork(tabs_manager, nodes=[], edges=[])
tab_id1 = tabs_manager.create_tab("Network 1", vis_network)
tab_id2 = tabs_manager.create_tab("Network 2", vis_network)
# Should create two different tabs
assert tab_id1 != tab_id2
assert len(tabs_manager.get_state().tabs) == 2
def test_tab_already_exists_returns_none_for_non_base_instance(self, tabs_manager):
"""Test that Div components are never considered duplicates."""
content = Div("Content")
tab_id1 = tabs_manager.create_tab("Tab", content)
tab_id2 = tabs_manager.create_tab("Tab", content)
# Should create two different tabs (Div is not BaseInstance)
assert tab_id1 != tab_id2
assert len(tabs_manager.get_state().tabs) == 2
# =========================================================================
# Edge Cases
# =========================================================================
def test_get_ordered_tabs_respects_order(self, tabs_manager):
"""Test that _get_ordered_tabs returns tabs in correct order."""
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
tab_id3 = tabs_manager.create_tab("Tab3", Div("Content 3"))
ordered = tabs_manager._get_ordered_tabs()
assert list(ordered.keys()) == [tab_id1, tab_id2, tab_id3]
def test_get_tab_list_returns_only_existing_tabs(self, tabs_manager):
"""Test that _get_tab_list is robust to inconsistent state."""
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
# Manually corrupt state (tab_id in order but not in tabs dict)
tabs_manager._state.tabs_order.append("fake_id")
tab_list = tabs_manager._get_tab_list()
# Should only return existing tabs
assert len(tab_list) == 2
assert all(tab["id"] in [tab_id1, tab_id2] for tab in tab_list)
def test_state_update_propagates_to_search(self, tabs_manager):
"""Test that state updates propagate to the Search component."""
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
# Search should have 1 item
assert len(tabs_manager._search.get_items()) == 1
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
# Search should have 2 items
assert len(tabs_manager._search.get_items()) == 2
tabs_manager.close_tab(tab_id1)
# Search should have 1 item again
assert len(tabs_manager._search.get_items()) == 1
def test_multiple_tabs_managers_in_same_session(self, root_instance):
"""Test that multiple TabsManager instances can coexist in same session."""
tm1 = TabsManager(root_instance, _id="tm1")
tm2 = TabsManager(root_instance, _id="tm2")
tm1.create_tab("Tab1", Div("Content 1"))
tm2.create_tab("Tab2", Div("Content 2"))
assert len(tm1.get_state().tabs) == 1
assert len(tm2.get_state().tabs) == 1
assert tm1.get_id() != tm2.get_id()
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)
"""Tests for TabsManager HTML rendering."""
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()
@pytest.fixture
def tabs_manager(self, root_instance):
"""Create a fresh TabsManager instance for render tests."""
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
tm = TabsManager(root_instance)
yield tm
InstancesManager.reset()
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
# =========================================================================
# Controller
# =========================================================================
def test_i_can_render_tabs_manager_with_no_tabs(self, tabs_manager):
"""Test that TabsManager renders with no tabs."""
html = 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"
),
Div(id=f"{tabs_manager.get_id()}-controller"), # controller
Div(id=f"{tabs_manager.get_id()}-header-wrapper"), # header
Div(id=f"{tabs_manager.get_id()}-content-wrapper"), # active content
id=tabs_manager.get_id(),
)
assert matches(res, expected)
assert matches(html, expected)
def test_controller_has_correct_id_and_attributes(self, tabs_manager):
"""Test that controller has correct ID, data attribute, and HTMX script.
Why these elements matter:
- id: Required for HTMX targeting in commands
- data_active_tab: Stores active tab ID for JavaScript access
- hx_on__after_settle: Triggers updateTabs() after HTMX swap completes
"""
tab_id = tabs_manager.create_tab("Tab1", Div("Content"))
controller = tabs_manager._mk_tabs_controller(oob=False)
expected = Div(
id=f"{tabs_manager.get_id()}-controller",
data_active_tab=tab_id,
hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");'
)
assert matches(controller, expected)
def test_controller_with_oob_false(self, tabs_manager):
"""Test that controller has no swap attribute when oob=False.
Why this matters:
- hx_swap_oob controls whether element swaps out-of-band
- When False, element swaps into target specified in command
"""
controller = tabs_manager._mk_tabs_controller(oob=False)
# Controller should not have hx_swap_oob attribute
assert not hasattr(controller, 'hx_swap_oob') or controller.attrs.get('hx_swap_oob') is None
def test_controller_with_oob_true(self, tabs_manager):
"""Test that controller has swap attribute when oob=True.
Why this matters:
- hx_swap_oob="true" enables out-of-band swapping
- Allows updating controller independently of main target
"""
controller = tabs_manager._mk_tabs_controller(oob=True)
expected = Div(hx_swap_oob="true")
assert matches(controller, expected)
# =========================================================================
# Header
# =========================================================================
def test_header_wrapper_with_no_tabs(self, tabs_manager):
"""Test that header wrapper renders empty when no tabs exist.
Why these elements matter:
- id: Required for targeting header in updates
- cls: Provides base styling for header
- Empty header div: Shows no tabs are present
- Search menu: Always present for adding tabs
"""
header = tabs_manager._mk_tabs_header_wrapper(oob=False)
# Should have no children (no tabs)
tab_buttons = find(header, Div(cls=Contains("mf-tab-button")))
assert len(tab_buttons) == 0, "Header should contain no tab buttons when empty"
def test_header_wrapper_with_multiple_tabs(self, tabs_manager):
"""Test that header wrapper renders all tab buttons.
Why these elements matter:
- Multiple Div elements: Each represents a tab button
- Order preservation: Tabs must appear in creation order
- cls mf-tab-button: Identifies tab buttons for styling/selection
"""
tabs_manager.create_tab("Tab1", Div("Content 1"))
tabs_manager.create_tab("Tab2", Div("Content 2"))
tabs_manager.create_tab("Tab3", Div("Content 3"))
header = tabs_manager._mk_tabs_header_wrapper(oob=False)
# Should have 3 tab buttons
tab_buttons = find(header, Div(cls=Contains("mf-tab-button")))
assert len(tab_buttons) == 3, "Header should contain exactly 3 tab buttons"
def test_header_wrapper_contains_search_menu(self, tabs_manager):
"""Test that header wrapper contains the search menu dropdown.
Why these elements matter:
- dropdown: DaisyUI dropdown container for tab search
- dropdown-end: Positions dropdown at end of header
- TestIcon for tabs icon: Visual indicator for menu
- Search component: Provides tab search functionality
"""
header = tabs_manager._mk_tabs_header_wrapper(oob=False)
# Find dropdown menu
dropdown = find_one(header, Div(cls=Contains("dropdown", "dropdown-end")))
# Should contain tabs icon
icon = find_one(dropdown, TestIcon("tabs24_regular"))
assert icon is not None, "Dropdown should contain tabs icon"
# Should contain Search component
search = find_one(dropdown, TestObject(tabs_manager._search.__class__))
assert search is not None, "Dropdown should contain Search component"
# =========================================================================
# Tab Button
# =========================================================================
def test_tab_button_for_active_tab(self, tabs_manager):
"""Test that active tab button has the active class.
Why these elements matter:
- cls mf-tab-active: Highlights the currently active tab
- data_tab_id: Links button to specific tab
- data_manager_id: Links button to TabsManager instance
"""
tab_id = tabs_manager.create_tab("Active Tab", Div("Content"))
tab_data = tabs_manager.get_state().tabs[tab_id]
button = tabs_manager._mk_tab_button(tab_data)
expected = Div(
cls=Contains("mf-tab-button", "mf-tab-active"),
data_tab_id=tab_id,
data_manager_id=tabs_manager.get_id()
)
assert matches(button, expected)
def test_tab_button_for_inactive_tab(self, tabs_manager):
"""Test that inactive tab button does not have active class.
Why these elements matter:
- cls without mf-tab-active: Indicates tab is not active
- Visual distinction: User can see which tab is selected
"""
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
# tab_id1 is now inactive
tab_data = tabs_manager.get_state().tabs[tab_id1]
button = tabs_manager._mk_tab_button(tab_data)
expected = Div(
cls=And(DoesNotContain("mf-tab-active"), Contains("mf-tab-button")),
data_tab_id=tab_id1,
data_manager_id=tabs_manager.get_id()
)
assert matches(button, expected)
def test_tab_button_has_label_and_close_icon(self, tabs_manager):
"""Test that tab button contains label and close icon.
Why these elements matter:
- Label Span: Displays tab name
- Close icon: Allows user to close the tab
- cls mf-tab-label: Styles the label text
- cls mf-tab-close-btn: Styles the close button
"""
tab_id = tabs_manager.create_tab("My Tab", Div("Content"))
tab_data = tabs_manager.get_state().tabs[tab_id]
button = tabs_manager._mk_tab_button(tab_data)
expected = Div(
Span("My Tab", cls=Contains("mf-tab-label")),
Span(TestIconNotStr("dismiss_circle16_regular"), cls=Contains("mf-tab-close-btn")),
)
assert matches(button, expected)
# =========================================================================
# Content
# =========================================================================
def test_content_wrapper_when_no_active_tab(self, tabs_manager):
"""Test that content wrapper shows 'No Content' when no tab is active.
Why these elements matter:
- id content-wrapper: Container for all tab contents
- 'No Content' message: Informs user no tab is selected
- cls hidden: Hides empty content by default
"""
wrapper = tabs_manager._mk_tab_content_wrapper()
# Should contain "No Content" message
content = find_one(wrapper, Div(cls=Contains("mf-empty-content")))
assert "No Content" in str(content)
def test_content_wrapper_when_tab_active(self, tabs_manager):
"""Test that content wrapper shows active tab content.
Why these elements matter:
- Active tab content is visible
- Content is wrapped in mf-tab-content div
- Correct tab ID in content div ID
"""
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
wrapper = tabs_manager._mk_tab_content_wrapper()
# global view from the wrapper
expected = Div(
Div(), # tab content, tested just after
id=f"{tabs_manager.get_id()}-content-wrapper",
cls=Contains("mf-tab-content-wrapper"),
)
assert matches(wrapper, expected)
# check if the content is present
tab_content = find_one(wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
expected = Div(
Div("My Content"), # <= content
cls=Contains("mf-tab-content"),
)
assert matches(tab_content, expected)
def test_tab_content_for_active_tab(self, tabs_manager):
"""Test that active tab content does not have hidden class.
Why these elements matter:
- No 'hidden' class: Content is visible to user
- Correct content ID: Links to specific tab
"""
content_div = Div("Test Content")
tab_id = tabs_manager.create_tab("Tab1", content_div)
tab_content_wrapper = tabs_manager._mk_tab_content(tab_id, content_div)
tab_content = find_one(tab_content_wrapper, Div(id=f"{tabs_manager.get_id()}-{tab_id}-content"))
# Should not have 'hidden' in classes
classes = tab_content.attrs.get('class', '')
assert 'hidden' not in classes
assert 'mf-tab-content' in classes
def test_tab_content_for_inactive_tab(self, tabs_manager):
"""Test that inactive tab content has hidden class.
Why these elements matter:
- 'hidden' class: Hides inactive tab content
- Content still in DOM: Enables fast tab switching
"""
tab_id1 = tabs_manager.create_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.create_tab("Tab2", Div("Content 2"))
# tab_id1 is now inactive
content_div = Div("Content 1")
tab_content = tabs_manager._mk_tab_content(tab_id1, content_div)
# Should have 'hidden' class
expected = Div(cls=Contains("hidden"))
assert matches(tab_content, expected)
def test_i_can_show_a_new_content(self, tabs_manager):
"""Test that TabsManager.show_tab() send the correct div to the client"""
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
actual = tabs_manager.show_tab(tab_id)
expected = (
Div(data_active_tab=tab_id,
hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");',
hx_swap_oob="true"), # the controller is correctly updated
Div(
id=f'{tabs_manager.get_id()}-header-wrapper',
hx_swap_oob="true"
), # content of the header
Div(
Div(Div("My Content")),
hx_swap_oob=f"beforeend:#{tabs_manager.get_id()}-content-wrapper", # hx_swap_oob="beforeend:" important !
), # content + where to put it
)
assert matches(actual, expected)
def test_i_can_show_content_after_switch(self, tabs_manager):
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
tabs_manager.show_tab(tab_id) # first time, send everything
actual = tabs_manager.show_tab(tab_id) # second time, send only the controller
expected = Div(data_active_tab=tab_id,
hx_on__after_settle=f'updateTabs("{tabs_manager.get_id()}-controller");',
hx_swap_oob="true")
assert matches(actual, expected)
def test_i_can_close_a_tab(self, tabs_manager):
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
tabs_manager.show_tab(tab_id) # was sent
actual = tabs_manager.close_tab(tab_id)
expected = (
Div(id=f'{tabs_manager.get_id()}-controller'),
Div(id=f'{tabs_manager.get_id()}-header-wrapper'),
Div(id=f'{tabs_manager.get_id()}-{tab_id}-content', hx_swap_oob="delete") # hx_swap_oob="delete" important !
)
assert matches(actual, expected)
def test_i_can_change_content(self, tabs_manager):
tab_id = tabs_manager.create_tab("Tab1", Div("My Content"))
tabs_manager.show_tab(tab_id)
actual = tabs_manager.change_tab_content(tab_id, "New Label", Div("New Content"))
expected = (
Div(data_active_tab=tab_id, hx_swap_oob="true"),
Div(id=f'{tabs_manager.get_id()}-header-wrapper', hx_swap_oob="true"),
Div(
Div("New Content"),
id=f'{tabs_manager.get_id()}-{tab_id}-content',
hx_swap_oob="outerHTML", # hx_swap_oob="true" important !
),
)
assert matches(actual, expected)
# =========================================================================
# Complete Render
# =========================================================================
def test_complete_render_with_no_tabs(self, tabs_manager):
"""Test complete render structure when no tabs exist.
Why these elements matter:
- cls mf-tabs-manager: Root container class for styling
- Controller: HTMX control and state management
- Header wrapper: Tab buttons container (empty)
- Content wrapper: Tab content container (shows 'No Content')
- Script: Initializes tabs on first render
"""
render = tabs_manager.render()
# Extract main elements
controller = find_one(render, Div(id=f"{tabs_manager.get_id()}-controller"))
header = find_one(render, Div(id=f"{tabs_manager.get_id()}-header-wrapper"))
content = find_one(render, Div(id=f"{tabs_manager.get_id()}-content-wrapper"))
script = find_one(render, TestScript(f'updateTabs("{tabs_manager.get_id()}-controller")'))
assert controller is not None, "Render should contain controller"
assert header is not None, "Render should contain header wrapper"
assert content is not None, "Render should contain content wrapper"
assert script is not None, "Render should contain initialization script"
def test_complete_render_with_multiple_tabs(self, tabs_manager):
"""Test complete render structure with multiple tabs.
Why these elements matter:
- 3 tab buttons: One for each created tab
- Active tab content visible: Shows current tab content
- Inactive tabs hidden: Lazy-loaded on activation
- Structure consistency: All components present
"""
tabs_manager.create_tab("Tab1", Div("Content 1"))
tabs_manager.create_tab("Tab2", Div("Content 2"))
tab_id3 = tabs_manager.create_tab("Tab3", Div("Content 3"))
render = tabs_manager.render()
# Find header
header_inner = find_one(render, Div(id=f"{tabs_manager.get_id()}-header"))
# Should have 3 tab buttons
tab_buttons = find(header_inner, Div(cls=Contains("mf-tab-button")))
assert len(tab_buttons) == 3, "Render should contain exactly 3 tab buttons"
# Content wrapper should show active tab (tab3)
active_content = find_one(render, Div(id=f"{tabs_manager.get_id()}-{tab_id3}-content"))
expected = Div(Div("Content 3"), cls=Contains("mf-tab-content"))
assert matches(active_content, expected), "Active tab content should be visible"

View File

@@ -6,7 +6,8 @@ from fasthtml.components import *
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.test.matcher import matches, TestObject, TestCommand
from myfasthtml.test.matcher import matches, TestObject, TestCommand, TestIcon, find_one, find, Contains, \
DoesNotContain
from .conftest import root_instance
@@ -376,14 +377,215 @@ class TestTreeviewBehaviour:
# Try to add sibling to node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._add_sibling("nonexistent_id")
def test_i_can_initialize_with_items_dict(self, root_instance):
"""Test that TreeView can be initialized with a dictionary of items."""
node1 = TreeNode(label="Node 1", type="folder")
node2 = TreeNode(label="Node 2", type="file")
items = {node1.id: node1, node2.id: node2}
tree_view = TreeView(root_instance, items=items)
assert len(tree_view._state.items) == 2
assert tree_view._state.items[node1.id].label == "Node 1"
assert tree_view._state.items[node1.id].type == "folder"
assert tree_view._state.items[node2.id].label == "Node 2"
assert tree_view._state.items[node2.id].type == "file"
def test_i_can_ensure_simple_path(self, root_instance):
"""Test that ensure_path creates a simple hierarchical path."""
tree_view = TreeView(root_instance)
tree_view.ensure_path("folder1.folder2.file")
# Should have 3 nodes
assert len(tree_view._state.items) == 3
# Find nodes by label
nodes = list(tree_view._state.items.values())
folder1 = [n for n in nodes if n.label == "folder1"][0]
folder2 = [n for n in nodes if n.label == "folder2"][0]
file_node = [n for n in nodes if n.label == "file"][0]
# Verify hierarchy
assert folder1.parent is None
assert folder2.parent == folder1.id
assert file_node.parent == folder2.id
# Verify children relationships
assert folder2.id in folder1.children
assert file_node.id in folder2.children
# Verify all nodes have folder type
assert folder1.type == "folder"
assert folder2.type == "folder"
assert file_node.type == "folder"
def test_i_can_ensure_path_with_existing_nodes(self, root_instance):
"""Test that ensure_path reuses existing nodes in the path."""
tree_view = TreeView(root_instance)
# Create initial path
tree_view.ensure_path("folder1.folder2.file1")
initial_count = len(tree_view._state.items)
# Ensure same path again
tree_view.ensure_path("folder1.folder2.file1")
# Should not create any new nodes
assert len(tree_view._state.items) == initial_count
assert len(tree_view._state.items) == 3
def test_i_can_ensure_path_partially_existing(self, root_instance):
"""Test that ensure_path creates only missing nodes when path partially exists."""
tree_view = TreeView(root_instance)
# Create initial path
tree_view.ensure_path("folder1.folder2")
assert len(tree_view._state.items) == 2
# Extend the path
tree_view.ensure_path("folder1.folder2.subfolder.file")
# Should have 4 nodes total (folder1, folder2, subfolder, file)
assert len(tree_view._state.items) == 4
# Find all nodes
nodes = list(tree_view._state.items.values())
folder1 = [n for n in nodes if n.label == "folder1"][0]
folder2 = [n for n in nodes if n.label == "folder2"][0]
subfolder = [n for n in nodes if n.label == "subfolder"][0]
file_node = [n for n in nodes if n.label == "file"][0]
# Verify new nodes are children of existing path
assert subfolder.parent == folder2.id
assert file_node.parent == subfolder.id
def test_i_cannot_ensure_path_with_none(self, root_instance):
"""Test that ensure_path raises ValueError when path is None."""
tree_view = TreeView(root_instance)
with pytest.raises(ValueError, match="Invalid path.*None"):
tree_view.ensure_path(None)
def test_i_cannot_ensure_path_with_empty_string(self, root_instance):
"""Test that ensure_path raises ValueError for empty strings after stripping."""
tree_view = TreeView(root_instance)
with pytest.raises(ValueError, match="Invalid path.*empty"):
tree_view.ensure_path(" ")
with pytest.raises(ValueError, match="Invalid path.*empty"):
tree_view.ensure_path("")
with pytest.raises(ValueError, match="Invalid path.*empty"):
tree_view.ensure_path("...")
def test_i_can_ensure_path_strips_leading_trailing_dots(self, root_instance):
"""Test that ensure_path strips leading and trailing dots."""
tree_view = TreeView(root_instance)
tree_view.ensure_path(".folder1.folder2.")
# Should only create 2 nodes (folder1, folder2)
assert len(tree_view._state.items) == 2
nodes = list(tree_view._state.items.values())
labels = [n.label for n in nodes]
assert "folder1" in labels
assert "folder2" in labels
def test_i_can_ensure_path_strips_spaces_in_parts(self, root_instance):
"""Test that ensure_path strips spaces from each path part."""
tree_view = TreeView(root_instance)
tree_view.ensure_path("folder1. folder2 . folder3 ")
# Should create 3 nodes with trimmed labels
assert len(tree_view._state.items) == 3
nodes = list(tree_view._state.items.values())
labels = [n.label for n in nodes]
assert "folder1" in labels
assert "folder2" in labels
assert "folder3" in labels
def test_ensure_path_creates_folder_type_nodes(self, root_instance):
"""Test that ensure_path creates nodes with type='folder'."""
tree_view = TreeView(root_instance)
tree_view.ensure_path("folder1.folder2")
for node in tree_view._state.items.values():
assert node.type == "folder"
def test_i_cannot_ensure_path_with_empty_parts(self, root_instance):
"""Test that ensure_path raises ValueError for paths with empty parts."""
tree_view = TreeView(root_instance)
with pytest.raises(ValueError, match="Invalid path"):
tree_view.ensure_path("folder1..folder2")
def test_i_cannot_ensure_path_with_only_spaces_parts(self, root_instance):
"""Test that ensure_path raises ValueError for path parts with only spaces."""
tree_view = TreeView(root_instance)
with pytest.raises(ValueError, match="Invalid path"):
tree_view.ensure_path("folder1. .folder2")
def test_ensure_path_returns_last_node_id(self, root_instance):
"""Test that ensure_path returns the ID of the last node in the path."""
tree_view = TreeView(root_instance)
# Create a path and get the returned ID
returned_id = tree_view.ensure_path("folder1.folder2.folder3")
# Verify the returned ID is not None
assert returned_id is not None
# Verify the returned ID corresponds to folder3
assert returned_id in tree_view._state.items
assert tree_view._state.items[returned_id].label == "folder3"
# Verify we can use this ID to add a child
leaf = TreeNode(label="file.txt", type="file")
tree_view.add_node(leaf, parent_id=returned_id)
assert leaf.parent == returned_id
assert leaf.id in tree_view._state.items[returned_id].children
def test_ensure_path_returns_existing_node_id(self, root_instance):
"""Test that ensure_path returns ID even when path already exists."""
tree_view = TreeView(root_instance)
# Create initial path
first_id = tree_view.ensure_path("folder1.folder2")
# Ensure same path again
second_id = tree_view.ensure_path("folder1.folder2")
# Should return the same ID
assert first_id == second_id
assert tree_view._state.items[first_id].label == "folder2"
class TestTreeViewRender:
"""Tests for TreeView HTML rendering."""
def test_empty_treeview_is_rendered(self, root_instance):
"""Test that TreeView generates correct HTML structure."""
tree_view = TreeView(root_instance)
@pytest.fixture
def tree_view(self, root_instance):
return TreeView(root_instance)
def test_empty_treeview_is_rendered(self, tree_view):
"""Test that empty TreeView generates correct HTML structure.
Why these elements matter:
- TestObject Keyboard: Essential for keyboard shortcuts (Escape to cancel rename)
- _id: Required for HTMX targeting and component identification
- cls "mf-treeview": Root CSS class for TreeView styling
"""
expected = Div(
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
_id=tree_view.get_id(),
@@ -392,7 +594,444 @@ class TestTreeViewRender:
assert matches(tree_view.__ft__(), expected)
def test_node_action_buttons_are_rendered(self):
"""Test that action buttons are present in rendered HTML."""
# Signature only - implementation later
pass
def test_node_with_children_collapsed_is_rendered(self, tree_view):
"""Test that a collapsed node with children renders correctly.
Why these elements matter:
- TestIcon chevron_right: Indicates visually that the node is collapsed
- Span with label: Displays the node's text content
- Action buttons (add_child, edit, delete): Enable user interactions
- cls "mf-treenode": Required CSS class for node styling
- data_node_id: Essential for identifying the node in DOM operations
- No children in container: Verifies children are hidden when collapsed
"""
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id)
# Step 1: Extract the node element to test
rendered = tree_view.render()
# Step 2: Define expected structure
expected = Div(
Div(
Div(
TestIcon("chevron_right20_regular"), # Collapsed toggle icon
Span("Parent"), # Label
Div( # Action buttons
TestIcon("add_circle20_regular"),
TestIcon("edit20_regular"),
TestIcon("delete20_regular"),
cls=Contains("mf-treenode-actions")
),
cls=Contains("mf-treenode"),
),
cls="mf-treenode-container",
data_node_id=parent.id
),
id=tree_view.get_id()
)
# Step 3: Compare
assert matches(rendered, expected)
# Verify no children are rendered (collapsed)
child_containers = find(rendered, Div(data_node_id=parent.id))
assert len(child_containers) == 1, "Children should not be rendered when node is collapsed"
def test_node_with_children_expanded_is_rendered(self, tree_view):
"""Test that an expanded node with children renders correctly.
Why these elements matter:
- TestIcon chevron_down: Indicates visually that the node is expanded
- Children rendered: Verifies that child nodes are visible when parent is expanded
- Child has its own node structure: Ensures recursive rendering works correctly
Rendered Structure :
Div (node_container with data_node_id)
├─ Div (information on current node - icon, label, actions)
└─ Div* (children - recursive containers, only if expanded)
"""
parent = TreeNode(label="Parent", type="folder")
child1 = TreeNode(label="Child1", type="file")
child2 = TreeNode(label="Child2", type="file")
tree_view.add_node(parent)
tree_view.add_node(child1, parent_id=parent.id)
tree_view.add_node(child2, parent_id=parent.id)
tree_view._toggle_node(parent.id) # Expand the parent
# Step 1: Extract the parent node element to test
rendered = tree_view.render()
parent_container = find_one(rendered, Div(data_node_id=parent.id))
expected = Div(
Div(), # parent info (see test_node_with_children_collapsed_is_rendered)
Div(data_node_id=child1.id),
Div(data_node_id=child2.id),
)
# Step 3: Compare
assert matches(parent_container, expected)
# now check the child node structure
child_container = find_one(rendered, Div(data_node_id=child1.id))
expected_child_container = Div(
Div(
Div(None), # No icon, the div is empty
Span("Child1"),
Div(), # action buttons
cls=Contains("mf-treenode")
),
cls="mf-treenode-container",
data_node_id=child1.id,
)
assert matches(child_container, expected_child_container)
def test_leaf_node_is_rendered(self, tree_view):
"""Test that a leaf node (no children) renders without toggle icon.
Why these elements matter:
- No toggle icon (or empty space): Leaf nodes don't need expand/collapse functionality
- Span with label: Displays the node's text content
- Action buttons present: Even leaf nodes can be edited, deleted, or receive children
"""
leaf = TreeNode(label="Leaf Node", type="file")
tree_view.add_node(leaf)
# Step 1: Extract the leaf node element to test
rendered = tree_view.render()
leaf_container = find_one(rendered, Div(data_node_id=leaf.id))
# Step 2: Define expected structure
expected = Div(
Div(
Div(None), # No icon, the div is empty
Span("Leaf Node"), # Label
Div(), # Action buttons still present
),
cls=Contains("mf-treenode"),
data_node_id=leaf.id
)
# Step 3: Compare
assert matches(leaf_container, expected)
def test_selected_node_has_selected_class(self, tree_view):
"""Test that a selected node has the 'selected' CSS class.
Why these elements matter:
- cls Contains "selected": Enables visual highlighting of the selected node
- Div with mf-treenode: The node information container with selected class
- data_node_id: Required for identifying which node is selected
"""
node = TreeNode(label="Selected Node", type="file")
tree_view.add_node(node)
tree_view._select_node(node.id)
rendered = tree_view.render()
selected_container = find_one(rendered, Div(data_node_id=node.id))
expected = Div(
Div(
Div(None), # No icon, leaf node
Span("Selected Node"),
Div(), # Action buttons
cls=Contains("mf-treenode", "selected")
),
cls="mf-treenode-container",
data_node_id=node.id
)
assert matches(selected_container, expected)
def test_node_in_editing_mode_shows_input(self, tree_view):
"""Test that a node in editing mode renders an Input instead of Span.
Why these elements matter:
- Input element: Enables user to modify the node label inline
- cls "mf-treenode-input": Required CSS class for input field styling
- name "node_label": Essential for form data submission
- value with current label: Pre-fills the input with existing text
- cls does NOT contain "selected": Avoids double highlighting during editing
"""
node = TreeNode(label="Edit Me", type="file")
tree_view.add_node(node)
tree_view._start_rename(node.id)
rendered = tree_view.render()
editing_container = find_one(rendered, Div(data_node_id=node.id))
expected = Div(
Div(
Div(None), # No icon, leaf node
Input(
name="node_label",
value="Edit Me",
cls=Contains("mf-treenode-input")
),
Div(), # Action buttons
cls=Contains("mf-treenode")
),
cls="mf-treenode-container",
data_node_id=node.id
)
assert matches(editing_container, expected)
# Verify "selected" class is NOT present
editing_node_info = find_one(editing_container, Div(cls=Contains("mf-treenode", _word=True)))
no_selected = Div(
cls=DoesNotContain("selected")
)
assert matches(editing_node_info, no_selected)
def test_node_indentation_increases_with_level(self, tree_view):
"""Test that node indentation increases correctly with hierarchy level.
Why these elements matter:
- style Contains "padding-left: 0px": Root node has no indentation
- style Contains "padding-left: 20px": Child is indented by 20px
- style Contains "padding-left: 40px": Grandchild is indented by 40px
- Progressive padding: Creates the visual hierarchy of the tree structure
- Padding is applied to the node info Div, not the container
"""
root = TreeNode(label="Root", type="folder")
child = TreeNode(label="Child", type="folder")
grandchild = TreeNode(label="Grandchild", type="file")
tree_view.add_node(root)
tree_view.add_node(child, parent_id=root.id)
tree_view.add_node(grandchild, parent_id=child.id)
# Expand all to make hierarchy visible
tree_view._toggle_node(root.id)
tree_view._toggle_node(child.id)
rendered = tree_view.render()
# Test root node (level 0)
root_container = find_one(rendered, Div(data_node_id=root.id))
root_expected = Div(
Div(
TestIcon("chevron_down20_regular"), # Expanded icon
Span("Root"),
Div(), # Action buttons
cls=Contains("mf-treenode"),
style=Contains("padding-left: 0px")
),
cls="mf-treenode-container",
data_node_id=root.id
)
assert matches(root_container, root_expected)
# Test child node (level 1)
child_container = find_one(rendered, Div(data_node_id=child.id))
child_expected = Div(
Div(
TestIcon("chevron_down20_regular"), # Expanded icon
Span("Child"),
Div(), # Action buttons
cls=Contains("mf-treenode"),
style=Contains("padding-left: 20px")
),
cls="mf-treenode-container",
data_node_id=child.id
)
assert matches(child_container, child_expected)
# Test grandchild node (level 2)
grandchild_container = find_one(rendered, Div(data_node_id=grandchild.id))
grandchild_expected = Div(
Div(
Div(None), # No icon, leaf node
Span("Grandchild"),
Div(), # Action buttons
cls=Contains("mf-treenode"),
style=Contains("padding-left: 40px")
),
cls="mf-treenode-container",
data_node_id=grandchild.id
)
assert matches(grandchild_container, grandchild_expected)
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
def test_toggle_icon_has_correct_command(self, tree_view):
"""Test that toggle icon has ToggleNode command.
Why these elements matter:
- Div wrapper with command: mk.icon() wraps SVG in Div with HTMX attributes
- TestIcon inside Div: Verifies correct chevron icon is displayed
- TestCommand "ToggleNode": Essential for HTMX to route to correct handler
- Command targets correct node_id: Ensures the right node is toggled
"""
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id)
# Step 1: Extract the parent node element
rendered = tree_view.render()
parent_node = find_one(rendered, Div(data_node_id=parent.id))
# Step 2: Define expected structure
expected = Div(
Div(
TestIcon("chevron_right20_regular", command=tree_view.commands.toggle_node(parent.id)),
),
data_node_id=parent.id
)
# Step 3: Compare
assert matches(parent_node, expected)
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
def test_action_buttons_have_correct_commands(self, tree_view):
"""Test that action buttons have correct commands.
Why these elements matter:
- add_circle icon with AddChild: Enables adding child nodes via HTMX
- edit icon with StartRename: Triggers inline editing mode
- delete icon with DeleteNode: Enables node deletion
- cls "mf-treenode-actions": Required CSS class for button container styling
"""
node = TreeNode(label="Node", type="folder")
tree_view.add_node(node)
# Step 1: Extract the action buttons container
rendered = tree_view.render()
actions = find_one(rendered, Div(cls=Contains("mf-treenode-actions")))
# Step 2: Define expected structure
expected = Div(
TestIcon("add_circle20_regular", command=tree_view.commands.add_child(node.id)),
TestIcon("edit20_regular", command=tree_view.commands.start_rename(node.id)),
TestIcon("delete20_regular", command=tree_view.commands.delete_node(node.id)),
cls=Contains("mf-treenode-actions")
)
# Step 3: Compare
assert matches(actions, expected)
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
def test_label_has_select_command(self, tree_view):
"""Test that node label has SelectNode command.
Why these elements matter:
- Span with node label: Displays the node text
- TestCommand "SelectNode": Clicking label selects the node via HTMX
- cls "mf-treenode-label": Required CSS class for label styling
"""
node = TreeNode(label="Clickable Node", type="file")
tree_view.add_node(node)
# Step 1: Extract the label element
rendered = tree_view.render()
label = find_one(rendered, Span(cls=Contains("mf-treenode-label")))
# Step 2: Define expected structure
expected = Span(
"Clickable Node",
command=tree_view.commands.select_node(node.id),
cls=Contains("mf-treenode-label")
)
# Step 3: Compare
assert matches(label, expected)
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
def test_input_has_save_rename_command(self, tree_view):
"""Test that editing input has SaveRename command.
Why these elements matter:
- Input element: Enables inline editing of node label
- TestCommand "SaveRename": Submits new label via HTMX on form submission
- name "node_label": Required for form data to include the new label value
- value with current label: Pre-fills input with existing node text
"""
node = TreeNode(label="Edit Me", type="file")
tree_view.add_node(node)
tree_view._start_rename(node.id)
# Step 1: Extract the input element
rendered = tree_view.render()
input_elem = find_one(rendered, Input(name="node_label"))
# Step 2: Define expected structure
expected = Input(
name="node_label",
value="Edit Me",
command=TestCommand(tree_view.commands.save_rename(node.id)),
cls=Contains("mf-treenode-input")
)
# Step 3: Compare
assert matches(input_elem, expected)
def test_keyboard_has_cancel_rename_command(self, tree_view):
"""Test that Keyboard component has Escape key bound to CancelRename.
Why these elements matter:
- TestObject Keyboard: Verifies keyboard shortcuts component is present
- esc combination with CancelRename: Enables canceling rename with Escape key
- Essential for UX: Users expect Escape to cancel inline editing
"""
# Step 1: Extract the Keyboard component
rendered = tree_view.render()
keyboard = find_one(rendered, TestObject(Keyboard))
# Step 2: Define expected structure
expected = TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")})
# Step 3: Compare
assert matches(keyboard, expected)
def test_multiple_root_nodes_are_rendered(self, tree_view):
"""Test that multiple root nodes are rendered at the same level.
Why these elements matter:
- Multiple root nodes: Verifies TreeView supports forest structure (multiple trees)
- All at same level: No artificial parent wrapping root nodes
- Each root has its own container: Proper structure for multiple independent trees
"""
root1 = TreeNode(label="Root 1", type="folder")
root2 = TreeNode(label="Root 2", type="folder")
tree_view.add_node(root1)
tree_view.add_node(root2)
rendered = tree_view.render()
root_containers = find(rendered, Div(cls=Contains("mf-treenode-container")))
assert len(root_containers) == 2, "Should have two root-level containers"
root1_container = find_one(rendered, Div(data_node_id=root1.id))
root2_container = find_one(rendered, Div(data_node_id=root2.id))
expected_root1 = Div(
Div(
Div(None), # No icon, leaf node
Span("Root 1"),
Div(), # Action buttons
cls=Contains("mf-treenode")
),
cls="mf-treenode-container",
data_node_id=root1.id
)
expected_root2 = Div(
Div(
Div(None), # No icon, leaf node
Span("Root 2"),
Div(), # Action buttons
cls=Contains("mf-treenode")
),
cls="mf-treenode-container",
data_node_id=root2.id
)
assert matches(root1_container, expected_root1)
assert matches(root2_container, expected_root2)

View File

@@ -27,21 +27,21 @@ def reset_command_manager():
class TestCommandDefault:
def test_i_can_create_a_command_with_no_params(self):
command = Command('test', 'Command description', callback)
command = Command('test', 'Command description', None, 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)
command = Command('test', 'Command description', None, 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)
command = Command('test', 'Command description', None, callback)
elt = Button()
updated = command.bind_ft(elt)
@@ -50,7 +50,7 @@ class TestCommandBind:
assert matches(updated, expected)
def test_i_can_suppress_swapping_with_target_attr(self):
command = Command('test', 'Command description', callback).htmx(target=None)
command = Command('test', 'Command description', None, callback).htmx(target=None)
elt = Button()
updated = command.bind_ft(elt)
@@ -70,7 +70,7 @@ class TestCommandBind:
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
command = Command('test', 'Command description', None, another_callback).bind(data)
res = command.execute()
@@ -88,14 +88,14 @@ class TestCommandBind:
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
command = Command('test', 'Command description', None, 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)
command = Command('test', 'Command description', None, callback)
elt = Button()
updated = command.bind_ft(elt)
@@ -113,7 +113,7 @@ class TestCommandBind:
def another_callback():
return return_values
command = Command('test', 'Command description', another_callback)
command = Command('test', 'Command description', None, another_callback)
res = command.execute()
@@ -125,7 +125,7 @@ class TestCommandBind:
class TestCommandExecute:
def test_i_can_create_a_command_with_no_params(self):
command = Command('test', 'Command description', callback)
command = Command('test', 'Command description', None, callback)
assert command.id is not None
assert command.name == 'test'
assert command.description == 'Command description'
@@ -137,7 +137,7 @@ class TestCommandExecute:
def callback_with_param(param):
return f"Hello {param}"
command = Command('test', 'Command description', callback_with_param, "world")
command = Command('test', 'Command description', None, callback_with_param, "world")
assert command.execute() == "Hello world"
def test_i_can_execute_a_command_with_open_parameter(self):
@@ -146,7 +146,7 @@ class TestCommandExecute:
def callback_with_param(name):
return f"Hello {name}"
command = Command('test', 'Command description', callback_with_param)
command = Command('test', 'Command description', None, callback_with_param)
assert command.execute(client_response={"name": "world"}) == "Hello world"
def test_i_can_convert_arg_in_execute(self):
@@ -155,7 +155,7 @@ class TestCommandExecute:
def callback_with_param(number: int):
assert isinstance(number, int)
command = Command('test', 'Command description', callback_with_param)
command = Command('test', 'Command description', None, callback_with_param)
command.execute(client_response={"number": "10"})
def test_swap_oob_is_added_when_multiple_elements_are_returned(self):
@@ -164,7 +164,7 @@ class TestCommandExecute:
def another_callback():
return Div(id="first"), Div(id="second"), "hello", Div(id="third")
command = Command('test', 'Command description', another_callback)
command = Command('test', 'Command description', None, another_callback)
res = command.execute()
assert "hx-swap-oob" not in res[0].attrs
@@ -177,7 +177,7 @@ class TestCommandExecute:
def another_callback():
return Div(id="first"), Div(), "hello", Div()
command = Command('test', 'Command description', another_callback)
command = Command('test', 'Command description', None, another_callback)
res = command.execute()
assert "hx-swap-oob" not in res[0].attrs
@@ -188,9 +188,9 @@ class TestCommandExecute:
class TestLambaCommand:
def test_i_can_create_a_command_from_lambda(self):
command = LambdaCommand(lambda resp: "Hello World")
command = LambdaCommand(None, lambda resp: "Hello World")
assert command.execute() == "Hello World"
def test_by_default_target_is_none(self):
command = LambdaCommand(lambda resp: "Hello World")
command = LambdaCommand(None, lambda resp: "Hello World")
assert command.get_htmx_params()["hx-swap"] == "none"

View File

@@ -91,7 +91,6 @@
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) {

View File

@@ -34,13 +34,13 @@ def rt(user):
class TestingCommand:
def test_i_can_trigger_a_command(self, user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
command = Command('test', 'TestingCommand', None, new_value, "this is my new value")
testable = TestableElement(user, mk.button('button', command))
testable.click()
assert user.get_content() == "this is my new value"
def test_error_is_raised_when_command_is_not_found(self, user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
command = Command('test', 'TestingCommand', None, new_value, "this is my new value")
CommandsManager.reset()
testable = TestableElement(user, mk.button('button', command))
@@ -50,7 +50,7 @@ class TestingCommand:
assert "not found." in str(exc_info.value)
def test_i_can_play_a_complex_scenario(self, user, rt):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
command = Command('test', 'TestingCommand', None, new_value, "this is my new value")
@rt('/')
def get(): return mk.button('button', command)

View File

@@ -1,7 +1,22 @@
import pytest
from fasthtml.components import Div, Span
from fastcore.basics import NotStr
from fasthtml.components import Div, Span, Main
from myfasthtml.test.matcher import find
from myfasthtml.test.matcher import find, TestObject, Contains, StartsWith
class Dummy:
def __init__(self, attr1, attr2=None):
self.attr1 = attr1
self.attr2 = attr2
def __eq__(self, other):
return (isinstance(other, Dummy)
and self.attr1 == other.attr1
and self.attr2 == other.attr2)
def __hash__(self):
return hash((self.attr1, self.attr2))
@pytest.mark.parametrize('ft, expected', [
@@ -9,10 +24,13 @@ from myfasthtml.test.matcher import find
(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")),
(Div(Div(id="id2"), id="id1"), Div(id="id1")),
(Dummy(attr1="value"), Dummy(attr1="value")),
(Dummy(attr1="value"), TestObject(Dummy, attr1="value")),
(Div(attr="value1 value2"), Div(attr=Contains("value1"))),
])
def test_i_can_find(ft, expected):
assert find(expected, expected) == [expected]
assert find(ft, expected) == [ft]
def test_find_element_by_id_in_a_list():
@@ -25,12 +43,41 @@ def test_find_element_by_id_in_a_list():
def test_i_can_find_sub_element():
a = Div(id="id1")
b = Div(a, id="id2")
c = Div(b, id="id3")
b = Span(a, id="id2")
c = Main(b, id="id3")
assert find(c, a) == [a]
def test_i_can_find_when_pattern_appears_also_in_children():
a1 = Div(id="id1")
b = Div(a1, id="id2")
a2 = Div(b, id="id1")
c = Main(a2, id="id3")
assert find(c, a1) == [a2, a1]
@pytest.mark.parametrize('ft, to_search, expected', [
(NotStr("hello"), NotStr("hello"), [NotStr("hello")]),
(NotStr("hello my friend"), NotStr("hello"), NotStr("hello my friend")),
(NotStr("hello"), TestObject(NotStr, s="hello"), [NotStr("hello")]),
(NotStr("hello my friend"), TestObject(NotStr, s=StartsWith("hello")), NotStr("hello my friend")),
])
def test_i_can_manage_notstr_success_path(ft, to_search, expected):
assert find(ft, to_search) == expected
@pytest.mark.parametrize('ft, to_search', [
(NotStr("my friend"), NotStr("hello")),
(NotStr("hello"), Dummy(attr1="hello")), # important, because of the internal __eq__ of NotStr
(NotStr("hello my friend"), TestObject(NotStr, s="hello")),
])
def test_test_i_can_manage_notstr_failure_path(ft, to_search):
res = find(ft, to_search)
assert res == []
@pytest.mark.parametrize('ft, expected', [
(None, Div(id="id1")),
(Span(id="id1"), Div(id="id1")),
@@ -38,5 +85,4 @@ def test_i_can_find_sub_element():
(Div(id="id2"), Div(id="id1")),
])
def test_i_cannot_find(ft, expected):
with pytest.raises(AssertionError):
find(expected, ft)
assert find(expected, ft) == []

View File

@@ -1,9 +1,10 @@
import pytest
from fastcore.basics import NotStr
from fasthtml.components import *
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.icons.fluent_p3 import add20_regular
from myfasthtml.test.matcher import *
from myfasthtml.test.testclient import MyFT
@@ -50,6 +51,12 @@ class TestMatches:
(Dummy(123, "value"), TestObject(Dummy, attr2="value")),
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123))),
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2="value")),
(mk.icon(add20_regular), TestIcon("Add20Regular")),
(mk.icon(add20_regular), TestIcon("add20_regular")),
(mk.icon(add20_regular), TestIcon()),
(Div(None, None, None, Div(id="to_find")), Div(Skip(None), Div(id="to_find"))),
(Div(Div(id="to_skip"), Div(id="to_skip"), Div(id="to_find")), Div(Skip(Div(id="to_skip")), Div(id="to_find"))),
(Div(hx_post="/url"), Div(HasHtmx(hx_post="/url"))),
])
def test_i_can_match(self, actual, expected):
assert matches(actual, expected)
@@ -93,8 +100,10 @@ class TestMatches:
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"),
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"),
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different"),
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")),
"The condition 'Contains(value2)' is not satisfied"),
(Div(Div(id="to_skip")), Div(Skip(Div(id="to_skip"))), "Nothing more to skip"),
(Div(hx_post="/url"), Div(HasHtmx(hx_post="/url2")), "The condition 'HasHtmx()' is not satisfied"),
])
def test_i_can_detect_errors(self, actual, expected, error_message):
with pytest.raises(AssertionError) as exc_info:
@@ -440,3 +449,33 @@ Error : The condition 'Contains(value2)' is not satisfied.
assert "\n" + res == '''
(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2")
^^^ |'''
class TestPredicates:
def test_i_can_validate_contains_with_words_only(self):
assert Contains("value", _word=True).validate("value value2 value3")
assert Contains("value", "value2", _word=True).validate("value value2 value3")
assert not Contains("value", _word=True).validate("valuevalue2value3")
assert not Contains("value value2", _word=True).validate("value value2 value3")
def test_i_can_validate_has_htmx(self):
div = Div(hx_post="/url")
assert HasHtmx(hx_post="/url").validate(div)
c = Command("c", "testing has_htmx", None, None)
c.bind_ft(div)
assert HasHtmx(command=c).validate(div)
def test_i_can_use_and(self):
contains1 = Contains("value1")
contains2 = Contains("value2")
not_contains1 = DoesNotContain("value1")
not_contains2 = DoesNotContain("value2")
assert And(contains1, contains2).validate("value1 value2")
assert And(contains1, not_contains2).validate("value1")
assert And(not_contains1, contains2).validate("value2")
assert And(not_contains1, not_contains2).validate("value3")
assert not And(contains1, not_contains2).validate("value2")