Compare commits
3 Commits
master
...
93cb477c21
| Author | SHA1 | Date | |
|---|---|---|---|
| 93cb477c21 | |||
| 96ed447eae | |||
| 1be75263ad |
594
docs/Layout.md
594
docs/Layout.md
@@ -1,505 +1,11 @@
|
|||||||
# Layout Component
|
# Layout control
|
||||||
|
|
||||||
## Introduction
|
## Overview
|
||||||
|
|
||||||
The Layout component provides a complete application structure with fixed header and footer, a scrollable main content
|
This component renders the global layout of the application.
|
||||||
area, and optional collapsible side drawers. It's designed to be the foundation of your FastHTML application's UI.
|
This is only one instance per session.
|
||||||
|
|
||||||
**Key features:**
|
## State
|
||||||
|
|
||||||
- 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 |
|
| Name | Type | Description | Default |
|
||||||
|----------------------|---------|----------------------------------|---------|
|
|----------------------|---------|----------------------------------|---------|
|
||||||
@@ -508,76 +14,38 @@ The Layout component maintains the following state properties:
|
|||||||
| `left_drawer_width` | integer | Width of the left drawer | 250 |
|
| `left_drawer_width` | integer | Width of the left drawer | 250 |
|
||||||
| `right_drawer_width` | integer | Width of the right drawer | 250 |
|
| `right_drawer_width` | integer | Width of the right drawer | 250 |
|
||||||
|
|
||||||
### Commands
|
## Commands
|
||||||
|
|
||||||
Available commands for programmatic control:
|
|
||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
|-----------------------------------------|----------------------------------------------------------------------------------------|
|
|-----------------------------------------|----------------------------------------------------------------------------------------|
|
||||||
| `toggle_drawer(side)` | Toggles the drawer on the specified side |
|
| `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 |
|
| `update_drawer_width(side, width=None)` | Updates the drawer width on the specified side. The width is given by the HTMX request |
|
||||||
|
|
||||||
### Public Methods
|
## Ids
|
||||||
|
|
||||||
| 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 |
|
| Name | Description |
|
||||||
|-------------|-------------------------------------|
|
|-------------|-------------------|
|
||||||
| `layout` | Root layout container (singleton) |
|
| `layout` | Singleton |
|
||||||
| `layout_h` | Header section (not currently used) |
|
| `layout_h` | header |
|
||||||
| `layout_hl` | Header left side |
|
| `layout_hl` | header left side |
|
||||||
| `layout_hr` | Header right side |
|
| `layout_hr` | header right side |
|
||||||
| `layout_f` | Footer section (not currently used) |
|
| `layout_f` | footer |
|
||||||
| `layout_fl` | Footer left side |
|
| `layout_fl` | footer left side |
|
||||||
| `layout_fr` | Footer right side |
|
| `layout_fr` | footer right side |
|
||||||
| `layout_ld` | Left drawer |
|
| `layout_ld` | left drawer |
|
||||||
| `layout_rd` | Right drawer |
|
| `layout_rd` | right drawer |
|
||||||
|
|
||||||
### Internal Methods
|
## High Level Hierarchical Structure
|
||||||
|
```
|
||||||
These methods are used internally for rendering:
|
MyFastHtml
|
||||||
|
├── src
|
||||||
| Method | Description |
|
│ ├── myfasthtml/ # Main library code
|
||||||
|---------------------------|--------------------------------------------------------|
|
│ │ ├── core/commands.py # Command definitions
|
||||||
| `_mk_header()` | Renders the header component |
|
│ │ ├── controls/button.py # Control helpers
|
||||||
| `_mk_footer()` | Renders the footer component |
|
│ │ └── pages/LoginPage.py # Predefined Login page
|
||||||
| `_mk_main()` | Renders the main content area |
|
│ └── ...
|
||||||
| `_mk_left_drawer()` | Renders the left drawer |
|
├── tests # Unit and integration tests
|
||||||
| `_mk_right_drawer()` | Renders the right drawer |
|
├── LICENSE # License file (MIT)
|
||||||
| `_mk_left_drawer_icon()` | Renders the left drawer toggle icon |
|
├── README.md # Project documentation
|
||||||
| `_mk_right_drawer_icon()` | Renders the right drawer toggle icon |
|
└── pyproject.toml # Build configuration
|
||||||
| `_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 |
|
|
||||||
596
docs/TreeView.md
596
docs/TreeView.md
@@ -1,596 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -38,7 +38,7 @@ mdurl==0.1.2
|
|||||||
more-itertools==10.8.0
|
more-itertools==10.8.0
|
||||||
myauth==0.2.1
|
myauth==0.2.1
|
||||||
mydbengine==0.1.0
|
mydbengine==0.1.0
|
||||||
myutils==0.5.0
|
myutils==0.4.0
|
||||||
nh3==0.3.1
|
nh3==0.3.1
|
||||||
numpy==2.3.5
|
numpy==2.3.5
|
||||||
oauthlib==3.3.1
|
oauthlib==3.3.1
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import yaml
|
|||||||
from fasthtml import serve
|
from fasthtml import serve
|
||||||
|
|
||||||
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
from myfasthtml.controls.CommandsDebugger import CommandsDebugger
|
||||||
from myfasthtml.controls.DataGridsManager import DataGridsManager
|
|
||||||
from myfasthtml.controls.Dropdown import Dropdown
|
from myfasthtml.controls.Dropdown import Dropdown
|
||||||
from myfasthtml.controls.FileUpload import FileUpload
|
from myfasthtml.controls.FileUpload import FileUpload
|
||||||
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
from myfasthtml.controls.InstancesDebugger import InstancesDebugger
|
||||||
@@ -75,7 +74,7 @@ def create_sample_treeview(parent):
|
|||||||
tree_view.add_node(todo, parent_id=documents.id)
|
tree_view.add_node(todo, parent_id=documents.id)
|
||||||
|
|
||||||
# Expand all nodes to show the full structure
|
# Expand all nodes to show the full structure
|
||||||
# tree_view.expand_all()
|
#tree_view.expand_all()
|
||||||
|
|
||||||
return tree_view
|
return tree_view
|
||||||
|
|
||||||
@@ -122,7 +121,6 @@ def index(session):
|
|||||||
layout.left_drawer.add(btn_file_upload, "Test")
|
layout.left_drawer.add(btn_file_upload, "Test")
|
||||||
layout.left_drawer.add(btn_popup, "Test")
|
layout.left_drawer.add(btn_popup, "Test")
|
||||||
layout.left_drawer.add(tree_view, "TreeView")
|
layout.left_drawer.add(tree_view, "TreeView")
|
||||||
layout.left_drawer.add(DataGridsManager(layout, _id="-datagrids"), "Documents")
|
|
||||||
layout.set_main(tabs_manager)
|
layout.set_main(tabs_manager)
|
||||||
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
|
keyboard = Keyboard(layout, _id="-keyboard").add("ctrl+o",
|
||||||
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
add_tab("File Open", FileUpload(layout, _id="-file_upload")))
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||||
--spacing: 0.25rem;
|
--spacing: 0.25rem;
|
||||||
--text-xs: 0.6875rem;
|
|
||||||
--text-sm: 0.875rem;
|
--text-sm: 0.875rem;
|
||||||
--text-sm--line-height: calc(1.25 / 0.875);
|
--text-sm--line-height: calc(1.25 / 0.875);
|
||||||
--text-xl: 1.25rem;
|
--text-xl: 1.25rem;
|
||||||
@@ -12,8 +11,6 @@
|
|||||||
--radius-md: 0.375rem;
|
--radius-md: 0.375rem;
|
||||||
--default-font-family: var(--font-sans);
|
--default-font-family: var(--font-sans);
|
||||||
--default-mono-font-family: var(--font-mono);
|
--default-mono-font-family: var(--font-mono);
|
||||||
--properties-font-size: var(--text-xs);
|
|
||||||
--mf-tooltip-zindex: 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -59,26 +56,6 @@
|
|||||||
* Compatible with DaisyUI 5
|
* Compatible with DaisyUI 5
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.mf-tooltip-container {
|
|
||||||
background: var(--color-base-200);
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
pointer-events: none; /* Prevent interfering with mouse events */
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
opacity: 0; /* Default to invisible */
|
|
||||||
visibility: hidden; /* Prevent interaction when invisible */
|
|
||||||
transition: opacity 0.3s ease, visibility 0s linear 0.3s; /* Delay visibility removal */
|
|
||||||
position: fixed; /* Keep it above other content and adjust position */
|
|
||||||
z-index: var(--mf-tooltip-zindex); /* Ensure it's on top */
|
|
||||||
}
|
|
||||||
|
|
||||||
.mf-tooltip-container[data-visible="true"] {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible; /* Show tooltip */
|
|
||||||
transition: opacity 0.3s ease; /* No delay when becoming visible */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Main layout container using CSS Grid */
|
/* Main layout container using CSS Grid */
|
||||||
.mf-layout {
|
.mf-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -655,6 +632,7 @@
|
|||||||
/* *************** Panel Component *************** */
|
/* *************** Panel Component *************** */
|
||||||
/* *********************************************** */
|
/* *********************************************** */
|
||||||
|
|
||||||
|
/* Container principal du panel */
|
||||||
.mf-panel {
|
.mf-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -663,6 +641,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Panel gauche */
|
||||||
.mf-panel-left {
|
.mf-panel-left {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -674,13 +653,15 @@
|
|||||||
border-right: 1px solid var(--color-border-primary);
|
border-right: 1px solid var(--color-border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Panel principal (centre) */
|
||||||
.mf-panel-main {
|
.mf-panel-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
min-width: 0; /* Important to allow the shrinking of flexbox */
|
min-width: 0; /* Important pour permettre le shrink du flexbox */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Panel droit */
|
||||||
.mf-panel-right {
|
.mf-panel-right {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -690,79 +671,4 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-left: 1px solid var(--color-border-primary);
|
border-left: 1px solid var(--color-border-primary);
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* *********************************************** */
|
|
||||||
/* ************* Properties Component ************ */
|
|
||||||
/* *********************************************** */
|
|
||||||
|
|
||||||
/* Properties container */
|
|
||||||
.mf-properties {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Group card - using DaisyUI card styling */
|
|
||||||
.mf-properties-group-card {
|
|
||||||
background-color: var(--color-base-100);
|
|
||||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Group header - gradient using DaisyUI primary color */
|
|
||||||
.mf-properties-group-header {
|
|
||||||
background: linear-gradient(135deg, var(--color-primary) 0%, color-mix(in oklab, var(--color-primary) 80%, black) 100%);
|
|
||||||
color: var(--color-primary-content);
|
|
||||||
padding: calc(var(--properties-font-size) * 0.5) calc(var(--properties-font-size) * 0.75);
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: var(--properties-font-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Group content area */
|
|
||||||
.mf-properties-group-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Property row */
|
|
||||||
.mf-properties-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: calc(var(--properties-font-size) * 0.4) calc(var(--properties-font-size) * 0.75);
|
|
||||||
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 5%, transparent);
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
gap: calc(var(--properties-font-size) * 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mf-properties-row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mf-properties-row:hover {
|
|
||||||
background-color: color-mix(in oklab, var(--color-base-content) 3%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Property key - normal font */
|
|
||||||
.mf-properties-key {
|
|
||||||
font-weight: 600;
|
|
||||||
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
|
|
||||||
flex: 0 0 40%;
|
|
||||||
font-size: var(--properties-font-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Property value - monospace font */
|
|
||||||
.mf-properties-value {
|
|
||||||
font-family: var(--default-mono-font-family);
|
|
||||||
color: var(--color-base-content);
|
|
||||||
flex: 1;
|
|
||||||
text-align: right;
|
|
||||||
font-size: var(--properties-font-size);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
@@ -159,113 +159,6 @@ 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 initBoundaries(elementId, updateUrl) {
|
||||||
function updateBoundaries() {
|
function updateBoundaries() {
|
||||||
const container = document.getElementById(elementId);
|
const container = document.getElementById(elementId);
|
||||||
@@ -470,6 +363,7 @@ function updateTabs(controllerId) {
|
|||||||
|
|
||||||
for (const [combinationStr, config] of Object.entries(combinations)) {
|
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||||
const sequence = parseCombination(combinationStr);
|
const sequence = parseCombination(combinationStr);
|
||||||
|
console.log("Parsing combination", combinationStr, "=>", sequence);
|
||||||
let currentNode = root;
|
let currentNode = root;
|
||||||
|
|
||||||
for (const keySet of sequence) {
|
for (const keySet of sequence) {
|
||||||
@@ -1461,4 +1355,3 @@ function updateTabs(controllerId) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ from myfasthtml.core.network_utils import from_parent_child_list
|
|||||||
|
|
||||||
|
|
||||||
class CommandsDebugger(SingleInstance):
|
class CommandsDebugger(SingleInstance):
|
||||||
"""
|
|
||||||
Represents a debugger designed for visualizing and managing commands in a parent-child
|
|
||||||
hierarchical structure.
|
|
||||||
"""
|
|
||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
from typing import Optional
|
|
||||||
|
|
||||||
from fasthtml.components import Div
|
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
|
||||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, DataGridFooterConf, \
|
|
||||||
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
|
||||||
|
|
||||||
|
|
||||||
class DatagridState(DbObject):
|
|
||||||
def __init__(self, owner):
|
|
||||||
super().__init__(owner)
|
|
||||||
with self.initializing():
|
|
||||||
self.sidebar_visible: bool = False
|
|
||||||
self.selected_view: str = None
|
|
||||||
self.row_index: bool = False
|
|
||||||
self.columns: list[DataGridColumnState] = []
|
|
||||||
self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
|
|
||||||
self.headers: list[DataGridHeaderFooterConf] = []
|
|
||||||
self.footers: list[DataGridHeaderFooterConf] = []
|
|
||||||
self.sorted: list = []
|
|
||||||
self.filtered: dict = {}
|
|
||||||
self.edition: DatagridEditionState = DatagridEditionState()
|
|
||||||
self.selection: DatagridSelectionState = DatagridSelectionState()
|
|
||||||
|
|
||||||
|
|
||||||
class DatagridSettings(DbObject):
|
|
||||||
def __init__(self, owner):
|
|
||||||
super().__init__(owner)
|
|
||||||
with self.initializing():
|
|
||||||
self.file_name: Optional[str] = None
|
|
||||||
self.selected_sheet_name: Optional[str] = None
|
|
||||||
self.header_visible: bool = True
|
|
||||||
self.filter_all_visible: bool = True
|
|
||||||
self.views_visible: bool = True
|
|
||||||
self.open_file_visible: bool = True
|
|
||||||
self.open_settings_visible: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class DataGrid(MultipleInstance):
|
|
||||||
def __init__(self, parent, settings=None, _id=None):
|
|
||||||
super().__init__(parent, _id=_id)
|
|
||||||
self._settings = DatagridSettings(self).update(settings)
|
|
||||||
self._state = DatagridState(self)
|
|
||||||
self.commands = Commands(self)
|
|
||||||
|
|
||||||
def render(self):
|
|
||||||
return Div(
|
|
||||||
self._id
|
|
||||||
)
|
|
||||||
|
|
||||||
def __ft__(self):
|
|
||||||
return self.render()
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import pandas as pd
|
|
||||||
from fasthtml.components import Div
|
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
|
||||||
from myfasthtml.controls.TabsManager import TabsManager
|
|
||||||
from myfasthtml.controls.TreeView import TreeView
|
|
||||||
from myfasthtml.controls.helpers import mk
|
|
||||||
from myfasthtml.core.commands import Command
|
|
||||||
from myfasthtml.core.instances import MultipleInstance, InstancesManager
|
|
||||||
from myfasthtml.icons.fluent_p1 import table_add20_regular
|
|
||||||
from myfasthtml.icons.fluent_p3 import folder_open20_regular
|
|
||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
|
||||||
def upload_from_source(self):
|
|
||||||
return Command("UploadFromSource", "Upload from source", self._owner.upload_from_source)
|
|
||||||
|
|
||||||
def new_grid(self):
|
|
||||||
return Command("NewGrid", "New grid", self._owner.new_grid)
|
|
||||||
|
|
||||||
def open_from_excel(self, tab_id, get_content_callback):
|
|
||||||
excel_content = get_content_callback()
|
|
||||||
return Command("OpenFromExcel", "Open from Excel", self._owner.open_from_excel, tab_id, excel_content)
|
|
||||||
|
|
||||||
|
|
||||||
class DataGridsManager(MultipleInstance):
|
|
||||||
def __init__(self, parent, _id=None):
|
|
||||||
super().__init__(parent, _id=_id)
|
|
||||||
self.tree = TreeView(self, _id="-treeview")
|
|
||||||
self.commands = Commands(self)
|
|
||||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
|
||||||
|
|
||||||
def upload_from_source(self):
|
|
||||||
from myfasthtml.controls.FileUpload import FileUpload
|
|
||||||
file_upload = FileUpload(self, _id="-file-upload", auto_register=False)
|
|
||||||
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
|
|
||||||
tab_id = self._tabs_manager.add_tab("Upload Datagrid", file_upload)
|
|
||||||
file_upload.on_ok = self.commands.open_from_excel(tab_id, file_upload.get_content)
|
|
||||||
return self._tabs_manager.show_tab(tab_id)
|
|
||||||
|
|
||||||
def open_from_excel(self, tab_id, excel_content):
|
|
||||||
df = pd.read_excel(excel_content)
|
|
||||||
content = df.to_html(index=False)
|
|
||||||
self._tabs_manager.switch(tab_id, content)
|
|
||||||
|
|
||||||
def render(self):
|
|
||||||
return Div(
|
|
||||||
Div(
|
|
||||||
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
|
|
||||||
mk.icon(table_add20_regular, tooltip="New grid"),
|
|
||||||
cls="flex"
|
|
||||||
),
|
|
||||||
self.tree,
|
|
||||||
id=self._id,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __ft__(self):
|
|
||||||
return self.render()
|
|
||||||
@@ -22,12 +22,6 @@ class DropdownState:
|
|||||||
|
|
||||||
|
|
||||||
class Dropdown(MultipleInstance):
|
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):
|
def __init__(self, parent, content=None, button=None, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.button = Div(button) if not isinstance(button, FT) else button
|
self.button = Div(button) if not isinstance(button, FT) else button
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import UploadFile
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.helpers import mk
|
from myfasthtml.controls.helpers import Ids, mk
|
||||||
from myfasthtml.core.commands import Command
|
from myfasthtml.core.commands import Command
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
@@ -24,7 +24,6 @@ class FileUploadState(DbObject):
|
|||||||
self.ns_file_name: str | None = None
|
self.ns_file_name: str | None = None
|
||||||
self.ns_sheets_names: list | None = None
|
self.ns_sheets_names: list | None = None
|
||||||
self.ns_selected_sheet_name: str | None = None
|
self.ns_selected_sheet_name: str | None = None
|
||||||
self.ns_file_content: bytes | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
class Commands(BaseCommands):
|
||||||
@@ -36,25 +35,17 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class FileUpload(MultipleInstance):
|
class FileUpload(MultipleInstance):
|
||||||
"""
|
|
||||||
Represents a file upload component.
|
|
||||||
|
|
||||||
This class provides functionality to handle the uploading process of a file,
|
def __init__(self, parent, _id=None):
|
||||||
extract sheet names from an Excel file, and enables users to select a specific
|
super().__init__(parent, _id=_id)
|
||||||
sheet for further processing. It integrates commands and state management
|
|
||||||
to ensure smooth operation within a parent application.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, parent, _id=None, **kwargs):
|
|
||||||
super().__init__(parent, _id=_id, **kwargs)
|
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self._state = FileUploadState(self)
|
self._state = FileUploadState(self)
|
||||||
|
|
||||||
def upload_file(self, file: UploadFile):
|
def upload_file(self, file: UploadFile):
|
||||||
logger.debug(f"upload_file: {file=}")
|
logger.debug(f"upload_file: {file=}")
|
||||||
if file:
|
if file:
|
||||||
self._state.ns_file_content = file.file.read()
|
file_content = file.file.read()
|
||||||
self._state.ns_sheets_names = self.get_sheets_names(self._state.ns_file_content)
|
self._state.ns_sheets_names = self.get_sheets_names(file_content)
|
||||||
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
|
self._state.ns_selected_sheet_name = self._state.ns_sheets_names[0] if len(self._state.ns_sheets_names) > 0 else 0
|
||||||
|
|
||||||
return self.mk_sheet_selector()
|
return self.mk_sheet_selector()
|
||||||
@@ -73,10 +64,6 @@ class FileUpload(MultipleInstance):
|
|||||||
cls="select select-bordered select-sm w-full ml-2"
|
cls="select select-bordered select-sm w-full ml-2"
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_content(self):
|
|
||||||
return self._state.ns_file_content
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_sheets_names(file_content):
|
def get_sheets_names(file_content):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from myfasthtml.controls.Panel import Panel
|
from myfasthtml.controls.Panel import Panel
|
||||||
from myfasthtml.controls.Properties import Properties
|
|
||||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||||
from myfasthtml.core.commands import Command
|
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||||
from myfasthtml.core.network_utils import from_parent_child_list
|
from myfasthtml.core.network_utils import from_parent_child_list
|
||||||
|
|
||||||
@@ -10,26 +8,12 @@ class InstancesDebugger(SingleInstance):
|
|||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self._panel = Panel(self, _id="-panel")
|
self._panel = Panel(self, _id="-panel")
|
||||||
self._command = Command("ShowInstance",
|
|
||||||
"Display selected Instance",
|
|
||||||
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
|
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
nodes, edges = self._get_nodes_and_edges()
|
nodes, edges = self._get_nodes_and_edges()
|
||||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command})
|
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||||
return self._panel.set_main(vis_network)
|
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):
|
def _get_nodes_and_edges(self):
|
||||||
instances = self._get_instances()
|
instances = self._get_instances()
|
||||||
nodes, edges = from_parent_child_list(
|
nodes, edges = from_parent_child_list(
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ from myfasthtml.core.instances import MultipleInstance
|
|||||||
|
|
||||||
|
|
||||||
class Keyboard(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):
|
def __init__(self, parent, combinations=None, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.combinations = combinations or {}
|
self.combinations = combinations or {}
|
||||||
|
|||||||
@@ -17,17 +17,15 @@ from myfasthtml.core.commands import Command
|
|||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
from myfasthtml.core.instances import SingleInstance
|
from myfasthtml.core.instances import SingleInstance
|
||||||
from myfasthtml.core.utils import get_id
|
from myfasthtml.core.utils import get_id
|
||||||
from myfasthtml.icons.fluent import panel_left_contract20_regular as left_drawer_contract
|
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_icon
|
||||||
from myfasthtml.icons.fluent import panel_left_expand20_regular as left_drawer_expand
|
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_icon
|
||||||
from myfasthtml.icons.fluent_p1 import panel_right_contract20_regular as right_drawer_contract
|
|
||||||
from myfasthtml.icons.fluent_p2 import panel_right_expand20_regular as right_drawer_expand
|
|
||||||
|
|
||||||
logger = logging.getLogger("LayoutControl")
|
logger = logging.getLogger("LayoutControl")
|
||||||
|
|
||||||
|
|
||||||
class LayoutState(DbObject):
|
class LayoutState(DbObject):
|
||||||
def __init__(self, owner, name=None):
|
def __init__(self, owner):
|
||||||
super().__init__(owner, name=name)
|
super().__init__(owner)
|
||||||
with self.initializing():
|
with self.initializing():
|
||||||
self.left_drawer_open: bool = True
|
self.left_drawer_open: bool = True
|
||||||
self.right_drawer_open: bool = True
|
self.right_drawer_open: bool = True
|
||||||
@@ -117,7 +115,7 @@ class Layout(SingleInstance):
|
|||||||
|
|
||||||
# Content storage
|
# Content storage
|
||||||
self._main_content = None
|
self._main_content = None
|
||||||
self._state = LayoutState(self, "default_layout")
|
self._state = LayoutState(self)
|
||||||
self._boundaries = Boundaries(self)
|
self._boundaries = Boundaries(self)
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self.left_drawer = self.Content(self)
|
self.left_drawer = self.Content(self)
|
||||||
@@ -138,18 +136,6 @@ class Layout(SingleInstance):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
def toggle_drawer(self, side: Literal["left", "right"]):
|
def toggle_drawer(self, side: Literal["left", "right"]):
|
||||||
"""
|
|
||||||
Toggle the state of a drawer (open or close) based on the specified side. This
|
|
||||||
method also generates the corresponding icon and drawer elements for the
|
|
||||||
selected side.
|
|
||||||
|
|
||||||
:param side: The side of the drawer to toggle. Must be either "left" or "right".
|
|
||||||
:type side: Literal["left", "right"]
|
|
||||||
:return: A tuple containing the updated drawer icon and drawer elements for
|
|
||||||
the specified side.
|
|
||||||
:rtype: Tuple[Any, Any]
|
|
||||||
:raises ValueError: If the provided `side` is not "left" or "right".
|
|
||||||
"""
|
|
||||||
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
|
logger.debug(f"Toggle drawer: {side=}, {self._state.left_drawer_open=}")
|
||||||
if side == "left":
|
if side == "left":
|
||||||
self._state.left_drawer_open = not self._state.left_drawer_open
|
self._state.left_drawer_open = not self._state.left_drawer_open
|
||||||
@@ -292,14 +278,7 @@ class Layout(SingleInstance):
|
|||||||
|
|
||||||
# Wrap content in scrollable container
|
# Wrap content in scrollable container
|
||||||
content_wrapper = Div(
|
content_wrapper = Div(
|
||||||
*[
|
*self.right_drawer.get_content(),
|
||||||
(
|
|
||||||
Div(cls="divider") if index > 0 else None,
|
|
||||||
group_ft,
|
|
||||||
*[item for item in self.right_drawer.get_content()[group_name]]
|
|
||||||
)
|
|
||||||
for index, (group_name, group_ft) in enumerate(self.right_drawer.get_groups())
|
|
||||||
],
|
|
||||||
cls="mf-layout-drawer-content"
|
cls="mf-layout-drawer-content"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -312,12 +291,12 @@ class Layout(SingleInstance):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _mk_left_drawer_icon(self):
|
def _mk_left_drawer_icon(self):
|
||||||
return mk.icon(left_drawer_contract if self._state.left_drawer_open else left_drawer_expand,
|
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
|
||||||
id=f"{self._id}_ldi",
|
id=f"{self._id}_ldi",
|
||||||
command=self.commands.toggle_drawer("left"))
|
command=self.commands.toggle_drawer("left"))
|
||||||
|
|
||||||
def _mk_right_drawer_icon(self):
|
def _mk_right_drawer_icon(self):
|
||||||
return mk.icon(right_drawer_contract if self._state.right_drawer_open else right_drawer_expand,
|
return mk.icon(right_drawer_icon if self._state.left_drawer_open else left_drawer_icon,
|
||||||
id=f"{self._id}_rdi",
|
id=f"{self._id}_rdi",
|
||||||
command=self.commands.toggle_drawer("right"))
|
command=self.commands.toggle_drawer("right"))
|
||||||
|
|
||||||
@@ -345,13 +324,12 @@ class Layout(SingleInstance):
|
|||||||
|
|
||||||
# Wrap everything in a container div
|
# Wrap everything in a container div
|
||||||
return Div(
|
return Div(
|
||||||
Div(id=f"tt_{self._id}", cls="mf-tooltip-container"), # container for the tooltips
|
|
||||||
self._mk_header(),
|
self._mk_header(),
|
||||||
self._mk_left_drawer(),
|
self._mk_left_drawer(),
|
||||||
self._mk_main(),
|
self._mk_main(),
|
||||||
self._mk_right_drawer(),
|
self._mk_right_drawer(),
|
||||||
self._mk_footer(),
|
self._mk_footer(),
|
||||||
Script(f"initLayout('{self._id}');"),
|
Script(f"initResizer('{self._id}');"),
|
||||||
id=self._id,
|
id=self._id,
|
||||||
cls="mf-layout",
|
cls="mf-layout",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ from myfasthtml.core.instances import MultipleInstance
|
|||||||
|
|
||||||
|
|
||||||
class Mouse(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):
|
def __init__(self, parent, _id=None, combinations=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.combinations = combinations or {}
|
self.combinations = combinations or {}
|
||||||
|
|||||||
@@ -38,15 +38,6 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class Panel(MultipleInstance):
|
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):
|
def __init__(self, parent, conf=None, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.conf = conf or PanelConf()
|
self.conf = conf or PanelConf()
|
||||||
@@ -67,11 +58,11 @@ class Panel(MultipleInstance):
|
|||||||
|
|
||||||
def set_right(self, right):
|
def set_right(self, right):
|
||||||
self._right = right
|
self._right = right
|
||||||
return Div(self._right, id=f"{self._id}_r")
|
return self
|
||||||
|
|
||||||
def set_left(self, left):
|
def set_left(self, left):
|
||||||
self._left = left
|
self._left = left
|
||||||
return Div(self._left, id=f"{self._id}_l")
|
return self
|
||||||
|
|
||||||
def _mk_right(self):
|
def _mk_right(self):
|
||||||
if not self.conf.right:
|
if not self.conf.right:
|
||||||
@@ -83,7 +74,7 @@ class Panel(MultipleInstance):
|
|||||||
data_side="right"
|
data_side="right"
|
||||||
)
|
)
|
||||||
|
|
||||||
return Div(resizer, Div(self._right, id=f"{self._id}_r"), cls="mf-panel-right")
|
return Div(resizer, self._right, cls="mf-panel-right")
|
||||||
|
|
||||||
def _mk_left(self):
|
def _mk_left(self):
|
||||||
if not self.conf.left:
|
if not self.conf.left:
|
||||||
@@ -95,7 +86,7 @@ class Panel(MultipleInstance):
|
|||||||
data_side="left"
|
data_side="left"
|
||||||
)
|
)
|
||||||
|
|
||||||
return Div(Div(self._left, id=f"{self._id}_l"), resizer, cls="mf-panel-left")
|
return Div(self._left, resizer, cls="mf-panel-left")
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
return Div(
|
return Div(
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
from fasthtml.components import Div
|
|
||||||
from myutils.ProxyObject import ProxyObject
|
|
||||||
|
|
||||||
from myfasthtml.core.instances import MultipleInstance
|
|
||||||
|
|
||||||
|
|
||||||
class Properties(MultipleInstance):
|
|
||||||
def __init__(self, parent, obj=None, groups: dict = None, _id=None):
|
|
||||||
super().__init__(parent, _id=_id)
|
|
||||||
self.obj = obj
|
|
||||||
self.groups = groups
|
|
||||||
self.properties_by_group = self._create_properties_by_group()
|
|
||||||
|
|
||||||
def set_obj(self, obj, groups: dict = None):
|
|
||||||
self.obj = obj
|
|
||||||
self.groups = groups
|
|
||||||
self.properties_by_group = self._create_properties_by_group()
|
|
||||||
|
|
||||||
def render(self):
|
|
||||||
return Div(
|
|
||||||
*[
|
|
||||||
Div(
|
|
||||||
Div(group_name if group_name is not None else "", cls="mf-properties-group-header"),
|
|
||||||
Div(
|
|
||||||
*[
|
|
||||||
Div(
|
|
||||||
Div(k, cls="mf-properties-key"),
|
|
||||||
Div(str(v), cls="mf-properties-value", title=str(v)),
|
|
||||||
cls="mf-properties-row"
|
|
||||||
)
|
|
||||||
for k, v in proxy.as_dict().items()
|
|
||||||
],
|
|
||||||
cls="mf-properties-group-content"
|
|
||||||
),
|
|
||||||
cls="mf-properties-group-card"
|
|
||||||
)
|
|
||||||
for group_name, proxy in self.properties_by_group.items()
|
|
||||||
],
|
|
||||||
id=self._id,
|
|
||||||
cls="mf-properties"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _create_properties_by_group(self):
|
|
||||||
if self.groups is None:
|
|
||||||
return {None: ProxyObject(self.obj, {"*": ""})}
|
|
||||||
|
|
||||||
return {k: ProxyObject(self.obj, v) for k, v in self.groups.items()}
|
|
||||||
|
|
||||||
def __ft__(self):
|
|
||||||
return self.render()
|
|
||||||
@@ -21,21 +21,6 @@ class Commands(BaseCommands):
|
|||||||
|
|
||||||
|
|
||||||
class Search(MultipleInstance):
|
class Search(MultipleInstance):
|
||||||
"""
|
|
||||||
Represents a component for managing and filtering a list of items.
|
|
||||||
It uses fuzzy matching and subsequence matching to filter items.
|
|
||||||
|
|
||||||
:ivar items_names: The name of the items used to filter.
|
|
||||||
:type items_names: str
|
|
||||||
:ivar items: The first set of items to filter.
|
|
||||||
:type items: list
|
|
||||||
:ivar filtered: A copy of the `items` list, representing the filtered items after a search operation.
|
|
||||||
:type filtered: list
|
|
||||||
:ivar get_attr: Callable function to extract string values from items for filtering.
|
|
||||||
:type get_attr: Callable[[Any], str]
|
|
||||||
:ivar template: Callable function to define how filtered items are rendered.
|
|
||||||
:type template: Callable[[Any], Any]
|
|
||||||
"""
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
parent: BaseInstance,
|
parent: BaseInstance,
|
||||||
_id=None,
|
_id=None,
|
||||||
|
|||||||
@@ -102,11 +102,7 @@ class TabsManager(MultipleInstance):
|
|||||||
tab_config = self._state.tabs[tab_id]
|
tab_config = self._state.tabs[tab_id]
|
||||||
if tab_config["component_type"] is None:
|
if tab_config["component_type"] is None:
|
||||||
return None
|
return None
|
||||||
try:
|
|
||||||
return InstancesManager.get(self._session, tab_config["component_id"])
|
return InstancesManager.get(self._session, tab_config["component_id"])
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error while retrieving tab content: {e}")
|
|
||||||
return Div("Tab not found.")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_tab_count():
|
def _get_tab_count():
|
||||||
@@ -207,11 +203,6 @@ class TabsManager(MultipleInstance):
|
|||||||
logger.debug(f" Content already exists. Just switch.")
|
logger.debug(f" Content already exists. Just switch.")
|
||||||
return self._mk_tabs_controller()
|
return self._mk_tabs_controller()
|
||||||
|
|
||||||
def switch_tab(self, tab_id, label, component, activate=True):
|
|
||||||
logger.debug(f"switch_tab {label=}, component={component}, activate={activate}")
|
|
||||||
self._add_or_update_tab(tab_id, label, component, activate)
|
|
||||||
return self.show_tab(tab_id) #
|
|
||||||
|
|
||||||
def close_tab(self, tab_id: str):
|
def close_tab(self, tab_id: str):
|
||||||
"""
|
"""
|
||||||
Close a tab and remove it from the tabs manager.
|
Close a tab and remove it from the tabs manager.
|
||||||
@@ -391,34 +382,6 @@ class TabsManager(MultipleInstance):
|
|||||||
def _get_tab_list(self):
|
def _get_tab_list(self):
|
||||||
return [self._state.tabs[tab_id] for tab_id in self._state.tabs_order if tab_id in self._state.tabs]
|
return [self._state.tabs[tab_id] for tab_id in self._state.tabs_order if tab_id in self._state.tabs]
|
||||||
|
|
||||||
def _add_or_update_tab(self, tab_id, label, component, activate):
|
|
||||||
state = self._state.copy()
|
|
||||||
|
|
||||||
# Extract component ID if the component has a get_id() method
|
|
||||||
component_type, component_id = None, None
|
|
||||||
if isinstance(component, BaseInstance):
|
|
||||||
component_type = component.get_prefix() if isinstance(component, BaseInstance) else type(component).__name__
|
|
||||||
component_id = component.get_id()
|
|
||||||
|
|
||||||
# Add tab metadata to state
|
|
||||||
state.tabs[tab_id] = {
|
|
||||||
'id': tab_id,
|
|
||||||
'label': label,
|
|
||||||
'component_type': component_type,
|
|
||||||
'component_id': component_id
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add the content
|
|
||||||
state._tabs_content[tab_id] = component
|
|
||||||
|
|
||||||
# Activate tab if requested
|
|
||||||
if activate:
|
|
||||||
state.active_tab = tab_id
|
|
||||||
|
|
||||||
# finally, update the state
|
|
||||||
self._state.update(state)
|
|
||||||
self._search.set_items(self._get_tab_list())
|
|
||||||
|
|
||||||
def update_boundaries(self):
|
def update_boundaries(self):
|
||||||
return Script(f"updateBoundaries('{self._id}');")
|
return Script(f"updateBoundaries('{self._id}');")
|
||||||
|
|
||||||
|
|||||||
@@ -334,11 +334,12 @@ class TreeView(MultipleInstance):
|
|||||||
|
|
||||||
# Toggle icon
|
# Toggle icon
|
||||||
toggle = mk.icon(
|
toggle = mk.icon(
|
||||||
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else None,
|
chevron_down20_regular if is_expanded else chevron_right20_regular if has_children else " ",
|
||||||
command=self.commands.toggle_node(node_id))
|
command=self.commands.toggle_node(node_id))
|
||||||
|
|
||||||
# Label or input for editing
|
# Label or input for editing
|
||||||
if is_editing:
|
if is_editing:
|
||||||
|
# TODO: Bind input to save_rename (Enter) and cancel_rename (Escape)
|
||||||
label_element = mk.mk(Input(
|
label_element = mk.mk(Input(
|
||||||
name="node_label",
|
name="node_label",
|
||||||
value=node.label,
|
value=node.label,
|
||||||
@@ -356,6 +357,7 @@ class TreeView(MultipleInstance):
|
|||||||
label_element,
|
label_element,
|
||||||
self._render_action_buttons(node_id),
|
self._render_action_buttons(node_id),
|
||||||
cls=f"mf-treenode flex {'selected' if is_selected and not is_editing else ''}",
|
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"
|
style=f"padding-left: {level * 20}px"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -370,8 +372,7 @@ class TreeView(MultipleInstance):
|
|||||||
return Div(
|
return Div(
|
||||||
node_element,
|
node_element,
|
||||||
*children_elements,
|
*children_elements,
|
||||||
cls="mf-treenode-container",
|
cls="mf-treenode-container"
|
||||||
data_node_id=node_id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
@@ -389,7 +390,7 @@ class TreeView(MultipleInstance):
|
|||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
*[self._render_node(node_id) for node_id in root_nodes],
|
*[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,
|
id=self._id,
|
||||||
cls="mf-treeview"
|
cls="mf-treeview"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,33 +25,19 @@ class VisNetworkState(DbObject):
|
|||||||
},
|
},
|
||||||
"physics": {"enabled": True}
|
"physics": {"enabled": True}
|
||||||
}
|
}
|
||||||
self.events_handlers: dict = {} # {event_name: command_url}
|
|
||||||
|
|
||||||
|
|
||||||
class VisNetwork(MultipleInstance):
|
class VisNetwork(MultipleInstance):
|
||||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None, events_handlers=None):
|
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
logger.debug(f"VisNetwork created with id: {self._id}")
|
logger.debug(f"VisNetwork created with id: {self._id}")
|
||||||
|
|
||||||
# possible events (expected in snake_case
|
|
||||||
# - select_node → selectNode
|
|
||||||
# - select → select
|
|
||||||
# - click → click
|
|
||||||
# - double_click → doubleClick
|
|
||||||
|
|
||||||
self._state = VisNetworkState(self)
|
self._state = VisNetworkState(self)
|
||||||
|
self._update_state(nodes, edges, options)
|
||||||
|
|
||||||
# Convert Commands to URLs
|
def _update_state(self, nodes, edges, options):
|
||||||
handlers_htmx_options = {
|
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}")
|
||||||
event_name: command.ajax_htmx_options()
|
if not nodes and not edges and not options:
|
||||||
for event_name, command in events_handlers.items()
|
|
||||||
} if events_handlers else {}
|
|
||||||
|
|
||||||
self._update_state(nodes, edges, options, handlers_htmx_options)
|
|
||||||
|
|
||||||
def _update_state(self, nodes, edges, options, events_handlers=None):
|
|
||||||
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}, {events_handlers=}")
|
|
||||||
if not nodes and not edges and not options and not events_handlers:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
state = self._state.copy()
|
state = self._state.copy()
|
||||||
@@ -61,8 +47,6 @@ class VisNetwork(MultipleInstance):
|
|||||||
state.edges = edges
|
state.edges = edges
|
||||||
if options is not None:
|
if options is not None:
|
||||||
state.options = options
|
state.options = options
|
||||||
if events_handlers is not None:
|
|
||||||
state.events_handlers = events_handlers
|
|
||||||
|
|
||||||
self._state.update(state)
|
self._state.update(state)
|
||||||
|
|
||||||
@@ -86,34 +70,6 @@ class VisNetwork(MultipleInstance):
|
|||||||
# Convert Python options to JS
|
# Convert Python options to JS
|
||||||
js_options = json.dumps(self._state.options, indent=2)
|
js_options = json.dumps(self._state.options, indent=2)
|
||||||
|
|
||||||
# Map Python event names to vis-network event names
|
|
||||||
event_name_map = {
|
|
||||||
"select_node": "selectNode",
|
|
||||||
"select": "select",
|
|
||||||
"click": "click",
|
|
||||||
"double_click": "doubleClick"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Generate event handlers JavaScript
|
|
||||||
event_handlers_js = ""
|
|
||||||
for event_name, command_htmx_options in self._state.events_handlers.items():
|
|
||||||
vis_event_name = event_name_map.get(event_name, event_name)
|
|
||||||
event_handlers_js += f"""
|
|
||||||
network.on('{vis_event_name}', function(params) {{
|
|
||||||
const event_data = {{
|
|
||||||
event_name: '{event_name}',
|
|
||||||
nodes: params.nodes,
|
|
||||||
edges: params.edges,
|
|
||||||
pointer: params.pointer
|
|
||||||
}};
|
|
||||||
htmx.ajax('POST', '{command_htmx_options['url']}', {{
|
|
||||||
values: {{event_data: JSON.stringify(event_data)}},
|
|
||||||
target: '{command_htmx_options['target']}',
|
|
||||||
swap: '{command_htmx_options['swap']}'
|
|
||||||
}});
|
|
||||||
}});
|
|
||||||
"""
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
Div(
|
Div(
|
||||||
id=self._id,
|
id=self._id,
|
||||||
@@ -136,7 +92,6 @@ class VisNetwork(MultipleInstance):
|
|||||||
}};
|
}};
|
||||||
const options = {js_options};
|
const options = {js_options};
|
||||||
const network = new vis.Network(container, data, options);
|
const network = new vis.Network(container, data, options);
|
||||||
{event_handlers_js}
|
|
||||||
}})();
|
}})();
|
||||||
""")
|
""")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -50,7 +50,6 @@ class mk:
|
|||||||
size=20,
|
size=20,
|
||||||
can_select=True,
|
can_select=True,
|
||||||
can_hover=False,
|
can_hover=False,
|
||||||
tooltip=None,
|
|
||||||
cls='',
|
cls='',
|
||||||
command: Command = None,
|
command: Command = None,
|
||||||
binding: Binding = None,
|
binding: Binding = None,
|
||||||
@@ -66,7 +65,6 @@ class mk:
|
|||||||
:param size: The size of the icon, specified in pixels. Defaults to 20.
|
: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_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 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 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 command: The command object defining the function to be executed on icon interaction.
|
||||||
:param binding: The binding object for configuring additional event listeners on the icon.
|
:param binding: The binding object for configuring additional event listeners on the icon.
|
||||||
@@ -81,10 +79,6 @@ class mk:
|
|||||||
cls,
|
cls,
|
||||||
kwargs)
|
kwargs)
|
||||||
|
|
||||||
if tooltip:
|
|
||||||
merged_cls = merge_classes(merged_cls, "mf-tooltip")
|
|
||||||
kwargs["data-tooltip"] = tooltip
|
|
||||||
|
|
||||||
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
|
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import inspect
|
import inspect
|
||||||
import json
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -98,14 +97,6 @@ class BaseCommand:
|
|||||||
def url(self):
|
def url(self):
|
||||||
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
||||||
|
|
||||||
def ajax_htmx_options(self):
|
|
||||||
return {
|
|
||||||
"url": self.url,
|
|
||||||
"target": self._htmx_extra.get("hx-target", "this"),
|
|
||||||
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
|
|
||||||
"values": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_ft(self):
|
def get_ft(self):
|
||||||
return self._ft
|
return self._ft
|
||||||
|
|
||||||
@@ -135,7 +126,7 @@ class Command(BaseCommand):
|
|||||||
def __init__(self, name, description, callback, *args, **kwargs):
|
def __init__(self, name, description, callback, *args, **kwargs):
|
||||||
super().__init__(name, description)
|
super().__init__(name, description)
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
self.callback_parameters = dict(inspect.signature(callback).parameters) if callback else {}
|
self.callback_parameters = dict(inspect.signature(callback).parameters)
|
||||||
self.args = args
|
self.args = args
|
||||||
self.kwargs = kwargs
|
self.kwargs = kwargs
|
||||||
|
|
||||||
@@ -150,17 +141,8 @@ class Command(BaseCommand):
|
|||||||
return float(value)
|
return float(value)
|
||||||
elif param.annotation == list:
|
elif param.annotation == list:
|
||||||
return value.split(",")
|
return value.split(",")
|
||||||
elif param.annotation == dict:
|
|
||||||
return json.loads(value)
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def ajax_htmx_options(self):
|
|
||||||
res = super().ajax_htmx_options()
|
|
||||||
if self.kwargs:
|
|
||||||
res["values"] |= self.kwargs
|
|
||||||
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
|
|
||||||
return res
|
|
||||||
|
|
||||||
def execute(self, client_response: dict = None):
|
def execute(self, client_response: dict = None):
|
||||||
ret_from_bindings = []
|
ret_from_bindings = []
|
||||||
|
|
||||||
|
|||||||
@@ -1,39 +1,5 @@
|
|||||||
from enum import Enum
|
|
||||||
|
|
||||||
DEFAULT_COLUMN_WIDTH = 100
|
|
||||||
|
|
||||||
ROUTE_ROOT = "/myfasthtml"
|
ROUTE_ROOT = "/myfasthtml"
|
||||||
|
|
||||||
|
|
||||||
class Routes:
|
class Routes:
|
||||||
Commands = "/commands"
|
Commands = "/commands"
|
||||||
Bindings = "/bindings"
|
Bindings = "/bindings"
|
||||||
|
|
||||||
|
|
||||||
class ColumnType(Enum):
|
|
||||||
RowIndex = "RowIndex"
|
|
||||||
Text = "Text"
|
|
||||||
Number = "Number"
|
|
||||||
Datetime = "DateTime"
|
|
||||||
Bool = "Boolean"
|
|
||||||
Choice = "Choice"
|
|
||||||
List = "List"
|
|
||||||
|
|
||||||
|
|
||||||
class ViewType(Enum):
|
|
||||||
Table = "Table"
|
|
||||||
Chart = "Chart"
|
|
||||||
Form = "Form"
|
|
||||||
|
|
||||||
|
|
||||||
class FooterAggregation(Enum):
|
|
||||||
Sum = "Sum"
|
|
||||||
Mean = "Mean"
|
|
||||||
Min = "Min"
|
|
||||||
Max = "Max"
|
|
||||||
Count = "Count"
|
|
||||||
FilteredSum = "FilteredSum"
|
|
||||||
FilteredMean = "FilteredMean"
|
|
||||||
FilteredMin = "FilteredMin"
|
|
||||||
FilteredMax = "FilteredMax"
|
|
||||||
FilteredCount = "FilteredCount"
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class DbObject:
|
|||||||
|
|
||||||
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
|
def __init__(self, owner: BaseInstance, name=None, db_manager=None):
|
||||||
self._owner = owner
|
self._owner = owner
|
||||||
self._name = name or owner.get_full_id()
|
self._name = name or self.__class__.__name__
|
||||||
self._db_manager = db_manager or DbManager(self._owner)
|
self._db_manager = db_manager or DbManager(self._owner)
|
||||||
|
|
||||||
self._finalize_initialization()
|
self._finalize_initialization()
|
||||||
@@ -112,7 +112,6 @@ class DbObject:
|
|||||||
setattr(self, k, v)
|
setattr(self, k, v)
|
||||||
self._save_self()
|
self._save_self()
|
||||||
self._initializing = old_state
|
self._initializing = old_state
|
||||||
return self
|
|
||||||
|
|
||||||
def copy(self):
|
def copy(self):
|
||||||
as_dict = self._get_properties().copy()
|
as_dict = self._get_properties().copy()
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ class BaseInstance:
|
|||||||
return self._prefix
|
return self._prefix
|
||||||
|
|
||||||
def get_full_id(self) -> str:
|
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]:
|
def get_full_parent_id(self) -> Optional[str]:
|
||||||
parent = self.get_parent()
|
parent = self.get_parent()
|
||||||
@@ -176,22 +176,11 @@ class InstancesManager:
|
|||||||
:param instance_id:
|
:param instance_id:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
session_id = InstancesManager.get_session_id(session)
|
key = (InstancesManager.get_session_id(session), instance_id)
|
||||||
key = (session_id, instance_id)
|
|
||||||
return InstancesManager.instances[key]
|
return InstancesManager.instances[key]
|
||||||
|
|
||||||
@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
|
@staticmethod
|
||||||
def get_session_id(session):
|
def get_session_id(session):
|
||||||
if isinstance(session, str):
|
|
||||||
return session
|
|
||||||
if session is None:
|
if session is None:
|
||||||
return "** NOT LOGGED IN **"
|
return "** NOT LOGGED IN **"
|
||||||
if "user_info" not in session:
|
if "user_info" not in session:
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from collections.abc import Callable
|
|||||||
ROOT_COLOR = "#ff9999"
|
ROOT_COLOR = "#ff9999"
|
||||||
GHOST_COLOR = "#cccccc"
|
GHOST_COLOR = "#cccccc"
|
||||||
|
|
||||||
|
|
||||||
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
||||||
"""
|
"""
|
||||||
Convert a list of nested dictionaries to vis.js nodes and edges format.
|
Convert a list of nested dictionaries to vis.js nodes and edges format.
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional, Any
|
from typing import Optional
|
||||||
|
|
||||||
from fastcore.basics import NotStr
|
from fastcore.basics import NotStr
|
||||||
from fastcore.xml import FT
|
from fastcore.xml import FT
|
||||||
|
|
||||||
from myfasthtml.core.commands import BaseCommand
|
|
||||||
from myfasthtml.core.utils import quoted_str, snake_to_pascal
|
from myfasthtml.core.utils import quoted_str, snake_to_pascal
|
||||||
from myfasthtml.test.testclient import MyFT
|
from myfasthtml.test.testclient import MyFT
|
||||||
|
|
||||||
@@ -70,15 +69,10 @@ class EndsWith(AttrPredicate):
|
|||||||
|
|
||||||
|
|
||||||
class Contains(AttrPredicate):
|
class Contains(AttrPredicate):
|
||||||
def __init__(self, *value, _word=False):
|
def __init__(self, *value):
|
||||||
super().__init__(value)
|
super().__init__(value)
|
||||||
self._word = _word
|
|
||||||
|
|
||||||
def validate(self, actual):
|
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)
|
return all(val in actual for val in self.value)
|
||||||
|
|
||||||
|
|
||||||
@@ -151,26 +145,6 @@ class AttributeForbidden(ChildrenPredicate):
|
|||||||
return element
|
return element
|
||||||
|
|
||||||
|
|
||||||
class HasHtmx(ChildrenPredicate):
|
|
||||||
def __init__(self, command: BaseCommand = None, **htmx_params):
|
|
||||||
super().__init__(None)
|
|
||||||
self.command = command
|
|
||||||
if command:
|
|
||||||
self.htmx_params = command.get_htmx_params() | htmx_params
|
|
||||||
else:
|
|
||||||
self.htmx_params = htmx_params
|
|
||||||
|
|
||||||
self.htmx_params = {k.replace("hx_", "hx-"): v for k, v in self.htmx_params.items()}
|
|
||||||
|
|
||||||
def validate(self, actual):
|
|
||||||
return all(actual.attrs.get(k) == v for k, v in self.htmx_params.items())
|
|
||||||
|
|
||||||
def to_debug(self, element):
|
|
||||||
for k, v in self.htmx_params.items():
|
|
||||||
element.attrs[k] = v
|
|
||||||
return element
|
|
||||||
|
|
||||||
|
|
||||||
class TestObject:
|
class TestObject:
|
||||||
def __init__(self, cls, **kwargs):
|
def __init__(self, cls, **kwargs):
|
||||||
self.cls = cls
|
self.cls = cls
|
||||||
@@ -178,29 +152,17 @@ class TestObject:
|
|||||||
|
|
||||||
|
|
||||||
class TestIcon(TestObject):
|
class TestIcon(TestObject):
|
||||||
def __init__(self, name: Optional[str] = '', command=None):
|
def __init__(self, name: Optional[str] = ''):
|
||||||
super().__init__("div")
|
super().__init__("div")
|
||||||
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
||||||
self.children = [
|
self.children = [
|
||||||
TestObject(NotStr, s=Regex(f'<svg name="\\w+-{self.name}'))
|
TestObject(NotStr, s=Regex(f'<svg name="\\w+-{self.name}'))
|
||||||
]
|
]
|
||||||
if command:
|
|
||||||
self.attrs |= command.get_htmx_params()
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'<div><svg name="{self.name}" .../></div>'
|
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):
|
class TestCommand(TestObject):
|
||||||
def __init__(self, name, **kwargs):
|
def __init__(self, name, **kwargs):
|
||||||
super().__init__("Command", **kwargs)
|
super().__init__("Command", **kwargs)
|
||||||
@@ -221,12 +183,6 @@ class DoNotCheck:
|
|||||||
desc: str = None
|
desc: str = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Skip:
|
|
||||||
element: Any
|
|
||||||
desc: str = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_type(x):
|
def _get_type(x):
|
||||||
if hasattr(x, "tag"):
|
if hasattr(x, "tag"):
|
||||||
return x.tag
|
return x.tag
|
||||||
@@ -259,34 +215,6 @@ def _get_children(x):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _str_element(element, expected=None, keep_open=None):
|
|
||||||
# compare to itself if no expected element is provided
|
|
||||||
if expected is None:
|
|
||||||
expected = element
|
|
||||||
|
|
||||||
if hasattr(element, "tag"):
|
|
||||||
# the attributes are compared to the expected element
|
|
||||||
elt_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in
|
|
||||||
[attr_name for attr_name in _get_attributes(expected) if attr_name is not None]}
|
|
||||||
|
|
||||||
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
|
|
||||||
tag_str = f"({element.tag} {elt_attrs_str}"
|
|
||||||
|
|
||||||
# manage the closing tag
|
|
||||||
if keep_open is False:
|
|
||||||
tag_str += " ...)" if len(element.children) > 0 else ")"
|
|
||||||
elif keep_open is True:
|
|
||||||
tag_str += "..." if elt_attrs_str == "" else " ..."
|
|
||||||
else:
|
|
||||||
# close the tag if there are no children
|
|
||||||
not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
|
|
||||||
if len(not_special_children) == 0: tag_str += ")"
|
|
||||||
return tag_str
|
|
||||||
|
|
||||||
else:
|
|
||||||
return quoted_str(element)
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorOutput:
|
class ErrorOutput:
|
||||||
def __init__(self, path, element, expected):
|
def __init__(self, path, element, expected):
|
||||||
self.path = path
|
self.path = path
|
||||||
@@ -311,14 +239,14 @@ class ErrorOutput:
|
|||||||
# first render the path hierarchy
|
# first render the path hierarchy
|
||||||
for p in self.path.split(".")[:-1]:
|
for p in self.path.split(".")[:-1]:
|
||||||
elt_name, attr_name, attr_value = self._unconstruct_path_item(p)
|
elt_name, attr_name, attr_value = self._unconstruct_path_item(p)
|
||||||
path_str = _str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
|
path_str = self._str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
|
||||||
self._add_to_output(f"{path_str}")
|
self._add_to_output(f"{path_str}")
|
||||||
self.indent += " "
|
self.indent += " "
|
||||||
|
|
||||||
# then render the element
|
# then render the element
|
||||||
if hasattr(self.expected, "tag") and hasattr(self.element, "tag"):
|
if hasattr(self.expected, "tag") and hasattr(self.element, "tag"):
|
||||||
# display the tag and its attributes
|
# display the tag and its attributes
|
||||||
tag_str = _str_element(self.element, self.expected)
|
tag_str = self._str_element(self.element, self.expected)
|
||||||
self._add_to_output(tag_str)
|
self._add_to_output(tag_str)
|
||||||
|
|
||||||
# Try to show where the differences are
|
# Try to show where the differences are
|
||||||
@@ -341,7 +269,7 @@ class ErrorOutput:
|
|||||||
|
|
||||||
# display the child
|
# display the child
|
||||||
element_child = self.element.children[element_index]
|
element_child = self.element.children[element_index]
|
||||||
child_str = _str_element(element_child, expected_child, keep_open=False)
|
child_str = self._str_element(element_child, expected_child, keep_open=False)
|
||||||
self._add_to_output(child_str)
|
self._add_to_output(child_str)
|
||||||
|
|
||||||
# manage errors (only when the expected is a FT element
|
# manage errors (only when the expected is a FT element
|
||||||
@@ -375,6 +303,34 @@ class ErrorOutput:
|
|||||||
def _add_to_output(self, msg):
|
def _add_to_output(self, msg):
|
||||||
self.output.append(f"{self.indent}{msg}")
|
self.output.append(f"{self.indent}{msg}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _str_element(element, expected=None, keep_open=None):
|
||||||
|
# compare to itself if no expected element is provided
|
||||||
|
if expected is None:
|
||||||
|
expected = element
|
||||||
|
|
||||||
|
if hasattr(element, "tag"):
|
||||||
|
# the attributes are compared to the expected element
|
||||||
|
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_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):
|
def _detect_error(self, element, expected):
|
||||||
"""
|
"""
|
||||||
Detect errors between element and expected, returning a visual marker string.
|
Detect errors between element and expected, returning a visual marker string.
|
||||||
@@ -587,30 +543,9 @@ class Matcher:
|
|||||||
if len(actual_children) < len(expected_children):
|
if len(actual_children) < len(expected_children):
|
||||||
self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected)
|
self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected)
|
||||||
|
|
||||||
actual_child_index, expected_child_index = 0, 0
|
for actual_child, expected_child in zip(actual_children, expected_children):
|
||||||
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)
|
assert self.matches(actual_child, expected_child)
|
||||||
|
|
||||||
actual_child_index += 1
|
|
||||||
expected_child_index += 1
|
|
||||||
|
|
||||||
def _match_list(self, actual, expected):
|
def _match_list(self, actual, expected):
|
||||||
"""Match list or tuple."""
|
"""Match list or tuple."""
|
||||||
if len(actual) < len(expected):
|
if len(actual) < len(expected):
|
||||||
@@ -690,7 +625,7 @@ class Matcher:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _debug(elt):
|
def _debug(elt):
|
||||||
"""Format an element for debug output."""
|
"""Format an element for debug output."""
|
||||||
return _str_element(elt, keep_open=False) if elt else "None"
|
return str(elt) if elt else "None"
|
||||||
|
|
||||||
|
|
||||||
def matches(actual, expected, path=""):
|
def matches(actual, expected, path=""):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
from myfasthtml.core.instances import SingleInstance
|
||||||
|
|
||||||
|
|
||||||
class RootInstanceForTests(SingleInstance):
|
class RootInstanceForTests(SingleInstance):
|
||||||
@@ -25,5 +25,4 @@ def session():
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def root_instance(session):
|
def root_instance(session):
|
||||||
InstancesManager.reset()
|
|
||||||
return RootInstanceForTests(session=session)
|
return RootInstanceForTests(session=session)
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import pytest
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.Layout import Layout
|
from myfasthtml.controls.Layout import Layout
|
||||||
from myfasthtml.controls.UserProfile import UserProfile
|
from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript
|
||||||
from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript, TestObject, AnyValue, Skip, \
|
|
||||||
TestIconNotStr
|
|
||||||
from .conftest import root_instance
|
from .conftest import root_instance
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +35,15 @@ class TestLayoutBehaviour:
|
|||||||
assert layout._main_content == content
|
assert layout._main_content == content
|
||||||
assert result == layout # Should return self for chaining
|
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):
|
def test_i_can_add_content_to_left_drawer(self, root_instance):
|
||||||
"""Test adding content to left drawer."""
|
"""Test adding content to left drawer."""
|
||||||
layout = Layout(root_instance, app_name="Test App")
|
layout = Layout(root_instance, app_name="Test App")
|
||||||
@@ -238,12 +245,11 @@ class TestLayoutRender:
|
|||||||
"""Test that Layout renders with all main structural sections.
|
"""Test that Layout renders with all main structural sections.
|
||||||
|
|
||||||
Why these elements matter:
|
Why these elements matter:
|
||||||
- 7 children: Verifies all main sections are rendered (tooltip container, header, drawers, main, footer, script)
|
- 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script)
|
||||||
- _id: Essential for layout identification and resizer initialization
|
- _id: Essential for layout identification and resizer initialization
|
||||||
- cls="mf-layout": Root CSS class for layout styling
|
- cls="mf-layout": Root CSS class for layout styling
|
||||||
"""
|
"""
|
||||||
expected = Div(
|
expected = Div(
|
||||||
Div(), # tooltip container
|
|
||||||
Header(),
|
Header(),
|
||||||
Div(), # left drawer
|
Div(), # left drawer
|
||||||
Main(),
|
Main(),
|
||||||
@@ -289,7 +295,7 @@ class TestLayoutRender:
|
|||||||
|
|
||||||
expected = Header(
|
expected = Header(
|
||||||
Div(
|
Div(
|
||||||
TestIcon("PanelLeftContract20Regular"),
|
TestIcon("panel_right_expand20_regular"),
|
||||||
cls="flex gap-1"
|
cls="flex gap-1"
|
||||||
),
|
),
|
||||||
cls="mf-layout-header"
|
cls="mf-layout-header"
|
||||||
@@ -346,7 +352,7 @@ class TestLayoutRender:
|
|||||||
|
|
||||||
expected = Div(
|
expected = Div(
|
||||||
_id=f"{layout._id}_ld",
|
_id=f"{layout._id}_ld",
|
||||||
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer", "collapsed"),
|
cls=Contains("collapsed"),
|
||||||
style=Contains("width: 0px")
|
style=Contains("width: 0px")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -385,7 +391,7 @@ class TestLayoutRender:
|
|||||||
|
|
||||||
expected = Div(
|
expected = Div(
|
||||||
_id=f"{layout._id}_rd",
|
_id=f"{layout._id}_rd",
|
||||||
cls=Contains("mf-layout-drawer", "mf-layout-right-drawer", "collapsed"),
|
cls=Contains("collapsed"),
|
||||||
style=Contains("width: 0px")
|
style=Contains("width: 0px")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -428,250 +434,34 @@ class TestLayoutRender:
|
|||||||
resizers = find(drawer, Div(cls=Contains("mf-resizer-right")))
|
resizers = find(drawer, Div(cls=Contains("mf-resizer-right")))
|
||||||
assert len(resizers) == 1, "Right drawer should contain exactly one resizer element"
|
assert len(resizers) == 1, "Right drawer should contain exactly one resizer element"
|
||||||
|
|
||||||
|
def test_drawer_groups_are_separated_by_dividers(self, layout):
|
||||||
|
"""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.left_drawer.add(Div("Item 1"), group="group1")
|
||||||
|
layout.left_drawer.add(Div("Item 2"), group="group2")
|
||||||
|
|
||||||
|
drawer = find(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||||
|
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, layout):
|
def test_resizer_script_is_included(self, layout):
|
||||||
"""Test that resizer initialization script is included in render.
|
"""Test that resizer initialization script is included in render.
|
||||||
|
|
||||||
Why this test matters:
|
Why this test matters:
|
||||||
- Script element: Required to initialize resizer functionality
|
- Script element: Required to initialize resizer functionality
|
||||||
- Script contains initLayout call: Ensures layout is activated for this layout instance
|
- Script contains initResizer call: Ensures resizer is activated for this layout instance
|
||||||
"""
|
"""
|
||||||
script = find_one(layout.render(), Script())
|
script = find_one(layout.render(), Script())
|
||||||
expected = TestScript(f"initLayout('{layout._id}');")
|
expected = TestScript(f"initResizer('{layout._id}');")
|
||||||
|
|
||||||
assert matches(script, expected)
|
|
||||||
|
|
||||||
def test_left_drawer_renders_content_with_groups(self, layout):
|
|
||||||
"""Test that left drawer renders content organized by groups with proper wrappers.
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- mf-layout-drawer-content wrapper: Required container for drawer scrolling behavior
|
|
||||||
- divider elements: Visual separation between content groups
|
|
||||||
- Group count validation: Ensures all added groups are rendered
|
|
||||||
"""
|
|
||||||
layout.left_drawer.add(Div("Item 1", id="item1"), group="group1")
|
|
||||||
layout.left_drawer.add(Div("Item 2", id="item2"), group="group2")
|
|
||||||
|
|
||||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
|
||||||
|
|
||||||
content_wrappers = find(drawer, Div(cls="mf-layout-drawer-content"))
|
|
||||||
assert len(content_wrappers) == 1, "Left drawer should contain exactly one content wrapper"
|
|
||||||
|
|
||||||
content = content_wrappers[0]
|
|
||||||
dividers = find(content, Div(cls="divider"))
|
|
||||||
assert len(dividers) == 1, "Two groups should be separated by exactly one divider"
|
|
||||||
|
|
||||||
|
|
||||||
def test_header_left_renders_custom_content(self, layout):
|
|
||||||
"""Test that custom content added to header_left is rendered in the left header section.
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- id="{layout._id}_hl": Essential for HTMX targeting during updates
|
|
||||||
- cls Contains "flex": Ensures horizontal layout of header items
|
|
||||||
- Icon presence: Toggle drawer icon must always be first element
|
|
||||||
- Custom content: Verifies header_left.add() correctly renders content
|
|
||||||
"""
|
|
||||||
custom_content = Div("Custom Header", id="custom_header")
|
|
||||||
layout.header_left.add(custom_content)
|
|
||||||
|
|
||||||
header_left = find_one(layout.render(), Div(id=f"{layout._id}_hl"))
|
|
||||||
|
|
||||||
expected = Div(
|
|
||||||
TestIcon(""),
|
|
||||||
Skip(None),
|
|
||||||
Div("Custom Header", id="custom_header"),
|
|
||||||
id=f"{layout._id}_hl",
|
|
||||||
cls=Contains("flex", "gap-1")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matches(header_left, expected)
|
|
||||||
|
|
||||||
def test_header_right_renders_custom_content(self, layout):
|
|
||||||
"""Test that custom content added to header_right is rendered in the right header section.
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- id="{layout._id}_hr": Essential for HTMX targeting during updates
|
|
||||||
- cls Contains "flex": Ensures horizontal layout of header items
|
|
||||||
- Custom content: Verifies header_right.add() correctly renders content
|
|
||||||
- UserProfile component: Must always be last element in right header
|
|
||||||
"""
|
|
||||||
custom_content = Div("Custom Header Right", id="custom_header_right")
|
|
||||||
layout.header_right.add(custom_content)
|
|
||||||
|
|
||||||
header_right = find_one(layout.render(), Div(id=f"{layout._id}_hr"))
|
|
||||||
|
|
||||||
expected = Div(
|
|
||||||
Skip(None),
|
|
||||||
Div("Custom Header Right", id="custom_header_right"),
|
|
||||||
TestObject(UserProfile),
|
|
||||||
id=f"{layout._id}_hr",
|
|
||||||
cls=Contains("flex", "gap-1")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matches(header_right, expected)
|
|
||||||
|
|
||||||
def test_footer_left_renders_custom_content(self, layout):
|
|
||||||
"""Test that custom content added to footer_left is rendered in the left footer section.
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- id="{layout._id}_fl": Essential for HTMX targeting during updates
|
|
||||||
- cls Contains "flex": Ensures horizontal layout of footer items
|
|
||||||
- Custom content: Verifies footer_left.add() correctly renders content
|
|
||||||
"""
|
|
||||||
custom_content = Div("Custom Footer Left", id="custom_footer_left")
|
|
||||||
layout.footer_left.add(custom_content)
|
|
||||||
|
|
||||||
footer_left = find_one(layout.render(), Div(id=f"{layout._id}_fl"))
|
|
||||||
|
|
||||||
expected = Div(
|
|
||||||
Skip(None),
|
|
||||||
Div("Custom Footer Left", id="custom_footer_left"),
|
|
||||||
id=f"{layout._id}_fl",
|
|
||||||
cls=Contains("flex", "gap-1")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matches(footer_left, expected)
|
|
||||||
|
|
||||||
def test_footer_right_renders_custom_content(self, layout):
|
|
||||||
"""Test that custom content added to footer_right is rendered in the right footer section.
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- id="{layout._id}_fr": Essential for HTMX targeting during updates
|
|
||||||
- cls Contains "flex": Ensures horizontal layout of footer items
|
|
||||||
- Custom content: Verifies footer_right.add() correctly renders content
|
|
||||||
"""
|
|
||||||
custom_content = Div("Custom Footer Right", id="custom_footer_right")
|
|
||||||
layout.footer_right.add(custom_content)
|
|
||||||
|
|
||||||
footer_right = find_one(layout.render(), Div(id=f"{layout._id}_fr"))
|
|
||||||
|
|
||||||
expected = Div(
|
|
||||||
Skip(None),
|
|
||||||
Div("Custom Footer Right", id="custom_footer_right"),
|
|
||||||
id=f"{layout._id}_fr",
|
|
||||||
cls=Contains("flex", "gap-1")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matches(footer_right, expected)
|
|
||||||
|
|
||||||
def test_left_drawer_resizer_has_command_data(self, layout):
|
|
||||||
"""Test that left drawer resizer has correct data attributes for command binding.
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- data_command_id: JavaScript uses this to trigger width update command
|
|
||||||
- data_side="left": JavaScript needs this to identify which drawer to resize
|
|
||||||
- cls Contains "mf-resizer-left": CSS uses this for left-specific positioning
|
|
||||||
"""
|
|
||||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
|
||||||
|
|
||||||
resizer = find_one(drawer, Div(cls=Contains("mf-resizer-left")))
|
|
||||||
|
|
||||||
expected = Div(
|
|
||||||
cls=Contains("mf-resizer", "mf-resizer-left"),
|
|
||||||
data_command_id=AnyValue(),
|
|
||||||
data_side="left"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matches(resizer, expected)
|
|
||||||
|
|
||||||
def test_right_drawer_resizer_has_command_data(self, layout):
|
|
||||||
"""Test that right drawer resizer has correct data attributes for command binding.
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- data_command_id: JavaScript uses this to trigger width update command
|
|
||||||
- data_side="right": JavaScript needs this to identify which drawer to resize
|
|
||||||
- cls Contains "mf-resizer-right": CSS uses this for right-specific positioning
|
|
||||||
"""
|
|
||||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
|
|
||||||
|
|
||||||
resizer = find_one(drawer, Div(cls=Contains("mf-resizer-right")))
|
|
||||||
|
|
||||||
expected = Div(
|
|
||||||
cls=Contains("mf-resizer", "mf-resizer-right"),
|
|
||||||
data_command_id=AnyValue(),
|
|
||||||
data_side="right"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matches(resizer, expected)
|
|
||||||
|
|
||||||
def test_left_drawer_icon_changes_when_closed(self, layout):
|
|
||||||
"""Test that left drawer toggle icon changes from expand to collapse when drawer is closed.
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- id="{layout._id}_ldi": Required for HTMX swap-oob updates when toggling
|
|
||||||
- Icon type: Visual feedback to user about drawer state (expand icon when closed)
|
|
||||||
- Icon change: Validates that toggle_drawer returns correct icon
|
|
||||||
"""
|
|
||||||
layout._state.left_drawer_open = False
|
|
||||||
|
|
||||||
icon_div = find_one(layout.render(), Div(id=f"{layout._id}_ldi"))
|
|
||||||
|
|
||||||
expected = Div(
|
|
||||||
TestIconNotStr("panel_left_expand20_regular"),
|
|
||||||
id=f"{layout._id}_ldi"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matches(icon_div, expected)
|
|
||||||
|
|
||||||
def test_left_drawer_icon_changes_when_opne(self, layout):
|
|
||||||
"""Test that left drawer toggle icon changes from collapse to expand when drawer is open..
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- id="{layout._id}_ldi": Required for HTMX swap-oob updates when toggling
|
|
||||||
- Icon type: Visual feedback to user about drawer state (expand icon when closed)
|
|
||||||
- Icon change: Validates that toggle_drawer returns correct icon
|
|
||||||
"""
|
|
||||||
layout._state.left_drawer_open = True
|
|
||||||
|
|
||||||
icon_div = find_one(layout.render(), Div(id=f"{layout._id}_ldi"))
|
|
||||||
|
|
||||||
expected = Div(
|
|
||||||
TestIconNotStr("panel_left_contract20_regular"),
|
|
||||||
id=f"{layout._id}_ldi"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matches(icon_div, expected)
|
|
||||||
|
|
||||||
def test_tooltip_container_is_rendered(self, layout):
|
|
||||||
"""Test that tooltip container is rendered at the top of the layout.
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- id="tt_{layout._id}": JavaScript uses this to append dynamically created tooltips
|
|
||||||
- cls Contains "mf-tooltip-container": CSS positioning for tooltip overlay layer
|
|
||||||
- Presence verification: Tooltips won't work if container is missing
|
|
||||||
"""
|
|
||||||
tooltip_container = find_one(layout.render(), Div(id=f"tt_{layout._id}"))
|
|
||||||
|
|
||||||
expected = Div(
|
|
||||||
id=f"tt_{layout._id}",
|
|
||||||
cls=Contains("mf-tooltip-container")
|
|
||||||
)
|
|
||||||
|
|
||||||
assert matches(tooltip_container, expected)
|
|
||||||
|
|
||||||
def test_header_right_contains_user_profile(self, layout):
|
|
||||||
"""Test that UserProfile component is rendered in the right header section.
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- UserProfile component: Provides authentication and user menu functionality
|
|
||||||
- Position in header right: Conventional placement for user profile controls
|
|
||||||
- Count verification: Ensures component is not duplicated
|
|
||||||
"""
|
|
||||||
header_right = find_one(layout.render(), Div(id=f"{layout._id}_hr"))
|
|
||||||
|
|
||||||
user_profiles = find(header_right, TestObject(UserProfile))
|
|
||||||
|
|
||||||
assert len(user_profiles) == 1, "Header right should contain exactly one UserProfile component"
|
|
||||||
|
|
||||||
def test_layout_initialization_script_is_included(self, layout):
|
|
||||||
"""Test that layout initialization script is included in render output.
|
|
||||||
|
|
||||||
Why these elements matter:
|
|
||||||
- Script presence: Required to initialize layout behavior (resizers, drawers)
|
|
||||||
- initLayout() call: Activates JavaScript functionality for this layout instance
|
|
||||||
- Layout ID parameter: Ensures initialization targets correct layout
|
|
||||||
"""
|
|
||||||
script = find_one(layout.render(), Script())
|
|
||||||
|
|
||||||
expected = TestScript(f"initLayout('{layout._id}');")
|
|
||||||
|
|
||||||
assert matches(script, expected)
|
assert matches(script, expected)
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ from fasthtml.components import *
|
|||||||
|
|
||||||
from myfasthtml.controls.Keyboard import Keyboard
|
from myfasthtml.controls.Keyboard import Keyboard
|
||||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||||
from myfasthtml.test.matcher import matches, TestObject, TestCommand, TestIcon, find_one, find, Contains, \
|
from myfasthtml.test.matcher import matches, TestObject, TestCommand
|
||||||
DoesNotContain
|
|
||||||
from .conftest import root_instance
|
from .conftest import root_instance
|
||||||
|
|
||||||
|
|
||||||
@@ -378,36 +377,13 @@ class TestTreeviewBehaviour:
|
|||||||
with pytest.raises(ValueError, match="Node.*does not exist"):
|
with pytest.raises(ValueError, match="Node.*does not exist"):
|
||||||
tree_view._add_sibling("nonexistent_id")
|
tree_view._add_sibling("nonexistent_id")
|
||||||
|
|
||||||
def test_i_can_initialize_with_items_dict(self, root_instance):
|
|
||||||
"""Test that TreeView can be initialized with a dictionary of items."""
|
|
||||||
node1 = TreeNode(label="Node 1", type="folder")
|
|
||||||
node2 = TreeNode(label="Node 2", type="file")
|
|
||||||
|
|
||||||
items = {node1.id: node1, node2.id: node2}
|
|
||||||
tree_view = TreeView(root_instance, items=items)
|
|
||||||
|
|
||||||
assert len(tree_view._state.items) == 2
|
|
||||||
assert tree_view._state.items[node1.id].label == "Node 1"
|
|
||||||
assert tree_view._state.items[node1.id].type == "folder"
|
|
||||||
assert tree_view._state.items[node2.id].label == "Node 2"
|
|
||||||
assert tree_view._state.items[node2.id].type == "file"
|
|
||||||
|
|
||||||
|
|
||||||
class TestTreeViewRender:
|
class TestTreeViewRender:
|
||||||
"""Tests for TreeView HTML rendering."""
|
"""Tests for TreeView HTML rendering."""
|
||||||
|
|
||||||
@pytest.fixture
|
def test_empty_treeview_is_rendered(self, root_instance):
|
||||||
def tree_view(self, root_instance):
|
"""Test that TreeView generates correct HTML structure."""
|
||||||
return TreeView(root_instance)
|
tree_view = 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(
|
expected = Div(
|
||||||
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
|
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
|
||||||
_id=tree_view.get_id(),
|
_id=tree_view.get_id(),
|
||||||
@@ -416,445 +392,7 @@ class TestTreeViewRender:
|
|||||||
|
|
||||||
assert matches(tree_view.__ft__(), expected)
|
assert matches(tree_view.__ft__(), expected)
|
||||||
|
|
||||||
def test_node_with_children_collapsed_is_rendered(self, tree_view):
|
def test_node_action_buttons_are_rendered(self):
|
||||||
"""Test that a collapsed node with children renders correctly.
|
"""Test that action buttons are present in rendered HTML."""
|
||||||
|
# Signature only - implementation later
|
||||||
Why these elements matter:
|
pass
|
||||||
- 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)
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
|
|
||||||
for (const [combinationStr, config] of Object.entries(combinations)) {
|
for (const [combinationStr, config] of Object.entries(combinations)) {
|
||||||
const sequence = parseCombination(combinationStr);
|
const sequence = parseCombination(combinationStr);
|
||||||
|
console.log("Parsing combination", combinationStr, "=>", sequence);
|
||||||
let currentNode = root;
|
let currentNode = root;
|
||||||
|
|
||||||
for (const keySet of sequence) {
|
for (const keySet of sequence) {
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ from fastcore.basics import NotStr
|
|||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.helpers import mk
|
from myfasthtml.controls.helpers import mk
|
||||||
from myfasthtml.core.commands import Command
|
|
||||||
from myfasthtml.icons.fluent_p3 import add20_regular
|
from myfasthtml.icons.fluent_p3 import add20_regular
|
||||||
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, ErrorOutput, \
|
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, ErrorOutput, \
|
||||||
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject, Skip, DoNotCheck, TestIcon, HasHtmx
|
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject, TestIcon, DoNotCheck
|
||||||
from myfasthtml.test.testclient import MyFT
|
from myfasthtml.test.testclient import MyFT
|
||||||
|
|
||||||
|
|
||||||
@@ -56,9 +55,6 @@ class TestMatches:
|
|||||||
(mk.icon(add20_regular), TestIcon("Add20Regular")),
|
(mk.icon(add20_regular), TestIcon("Add20Regular")),
|
||||||
(mk.icon(add20_regular), TestIcon("add20_regular")),
|
(mk.icon(add20_regular), TestIcon("add20_regular")),
|
||||||
(mk.icon(add20_regular), TestIcon()),
|
(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):
|
def test_i_can_match(self, actual, expected):
|
||||||
assert matches(actual, expected)
|
assert matches(actual, expected)
|
||||||
@@ -104,8 +100,7 @@ class TestMatches:
|
|||||||
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different"),
|
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different"),
|
||||||
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")),
|
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")),
|
||||||
"The condition 'Contains(value2)' is not satisfied"),
|
"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):
|
def test_i_can_detect_errors(self, actual, expected, error_message):
|
||||||
with pytest.raises(AssertionError) as exc_info:
|
with pytest.raises(AssertionError) as exc_info:
|
||||||
@@ -451,20 +446,3 @@ Error : The condition 'Contains(value2)' is not satisfied.
|
|||||||
assert "\n" + res == '''
|
assert "\n" + res == '''
|
||||||
(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2")
|
(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2")
|
||||||
^^^ |'''
|
^^^ |'''
|
||||||
|
|
||||||
|
|
||||||
class TestPredicates:
|
|
||||||
def test_i_can_validate_contains_with_words_only(self):
|
|
||||||
assert Contains("value", _word=True).validate("value value2 value3")
|
|
||||||
assert Contains("value", "value2", _word=True).validate("value value2 value3")
|
|
||||||
|
|
||||||
assert not Contains("value", _word=True).validate("valuevalue2value3")
|
|
||||||
assert not Contains("value value2", _word=True).validate("value value2 value3")
|
|
||||||
|
|
||||||
def test_i_can_validate_has_htmx(self):
|
|
||||||
div = Div(hx_post="/url")
|
|
||||||
assert HasHtmx(hx_post="/url").validate(div)
|
|
||||||
|
|
||||||
c = Command("c", "testing has_htmx", None)
|
|
||||||
c.bind_ft(div)
|
|
||||||
assert HasHtmx(command=c).validate(div)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user