Compare commits
2 Commits
0bd56c7f09
...
3abfab8e97
| Author | SHA1 | Date | |
|---|---|---|---|
| 3abfab8e97 | |||
| 7f3e6270a2 |
@@ -685,6 +685,56 @@ assert matches(element, expected)
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### UTR-16: Propose Parameterized Tests
|
||||||
|
|
||||||
|
**Rule:** When proposing a test plan, systematically identify tests that can be parameterized and propose them as such.
|
||||||
|
|
||||||
|
**When to parameterize:**
|
||||||
|
- Tests that follow the same pattern with different input values
|
||||||
|
- Tests that verify the same behavior for different sides/directions (left/right, up/down)
|
||||||
|
- Tests that check the same logic with different states (visible/hidden, enabled/disabled)
|
||||||
|
- Tests that validate the same method with different valid inputs
|
||||||
|
|
||||||
|
**How to identify candidates:**
|
||||||
|
1. Look for tests with similar names differing only by a value (e.g., `test_left_panel_...` and `test_right_panel_...`)
|
||||||
|
2. Look for tests that have identical structure but different parameters
|
||||||
|
3. Look for combinatorial scenarios (side × state combinations)
|
||||||
|
|
||||||
|
**How to propose:**
|
||||||
|
In your test plan, explicitly show:
|
||||||
|
1. The individual tests that would be written without parameterization
|
||||||
|
2. The parameterized version with all test cases
|
||||||
|
3. The reduction in test count
|
||||||
|
|
||||||
|
**Example proposal:**
|
||||||
|
|
||||||
|
```
|
||||||
|
**Without parameterization (4 tests):**
|
||||||
|
- test_i_can_toggle_left_panel_from_visible_to_hidden
|
||||||
|
- test_i_can_toggle_left_panel_from_hidden_to_visible
|
||||||
|
- test_i_can_toggle_right_panel_from_visible_to_hidden
|
||||||
|
- test_i_can_toggle_right_panel_from_hidden_to_visible
|
||||||
|
|
||||||
|
**With parameterization (1 test, 4 cases):**
|
||||||
|
@pytest.mark.parametrize("side, initial, expected", [
|
||||||
|
("left", True, False),
|
||||||
|
("left", False, True),
|
||||||
|
("right", True, False),
|
||||||
|
("right", False, True),
|
||||||
|
])
|
||||||
|
def test_i_can_toggle_panel_visibility(...)
|
||||||
|
|
||||||
|
**Result:** 1 test instead of 4, same coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Reduces code duplication
|
||||||
|
- Makes it easier to add new test cases
|
||||||
|
- Improves maintainability
|
||||||
|
- Makes the test matrix explicit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Managing Rules
|
## Managing Rules
|
||||||
|
|
||||||
To disable a specific rule, the user can say:
|
To disable a specific rule, the user can say:
|
||||||
|
|||||||
146
docs/Panel.md
146
docs/Panel.md
@@ -9,6 +9,7 @@ panels.
|
|||||||
**Key features:**
|
**Key features:**
|
||||||
|
|
||||||
- Three customizable zones (left panel, main content, right panel)
|
- Three customizable zones (left panel, main content, right panel)
|
||||||
|
- Configurable panel titles with sticky headers
|
||||||
- Toggle visibility with hide/show icons
|
- Toggle visibility with hide/show icons
|
||||||
- Resizable panels with drag handles
|
- Resizable panels with drag handles
|
||||||
- Smooth CSS animations for show/hide transitions
|
- Smooth CSS animations for show/hide transitions
|
||||||
@@ -108,10 +109,37 @@ The Panel component consists of three zones with optional side panels:
|
|||||||
| Left panel | Optional collapsible panel (default: visible) |
|
| Left panel | Optional collapsible panel (default: visible) |
|
||||||
| Main content | Always-visible central content area |
|
| Main content | Always-visible central content area |
|
||||||
| Right panel | Optional collapsible panel (default: visible) |
|
| Right panel | Optional collapsible panel (default: visible) |
|
||||||
| Hide icon (−) | Inside each panel, top right corner |
|
| Hide icon (−) | Inside each panel header, right side |
|
||||||
| Show icon (⋯) | In main area when panel is hidden |
|
| Show icon (⋯) | In main area when panel is hidden |
|
||||||
| Resizer (║) | Drag handle to resize panels manually |
|
| Resizer (║) | Drag handle to resize panels manually |
|
||||||
|
|
||||||
|
**Panel with title (default):**
|
||||||
|
|
||||||
|
When `show_left_title` or `show_right_title` is `True` (default), panels display a sticky header with title and hide icon:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Title [−] │ ← Header (sticky, always visible)
|
||||||
|
├─────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ Scrollable Content │ ← Content area (scrolls independently)
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Panel without title:**
|
||||||
|
|
||||||
|
When `show_left_title` or `show_right_title` is `False`, panels use the legacy layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ [−] │ ← Hide icon at top-right (absolute)
|
||||||
|
│ │
|
||||||
|
│ Content │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
### Creating a Panel
|
### Creating a Panel
|
||||||
|
|
||||||
The Panel is a `MultipleInstance`, meaning you can create multiple independent panels in your application. Create it by
|
The Panel is a `MultipleInstance`, meaning you can create multiple independent panels in your application. Create it by
|
||||||
@@ -196,7 +224,7 @@ panel = Panel(parent=root_instance)
|
|||||||
|
|
||||||
### Panel Configuration
|
### Panel Configuration
|
||||||
|
|
||||||
By default, both left and right panels are enabled. You can customize this with `PanelConf`:
|
By default, both left and right panels are enabled with titles. You can customize this with `PanelConf`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from myfasthtml.controls.Panel import PanelConf
|
from myfasthtml.controls.Panel import PanelConf
|
||||||
@@ -218,6 +246,49 @@ conf = PanelConf(left=False, right=False)
|
|||||||
panel = Panel(parent=root_instance, conf=conf)
|
panel = Panel(parent=root_instance, conf=conf)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Customizing panel titles:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Custom titles for panels
|
||||||
|
conf = PanelConf(
|
||||||
|
left=True,
|
||||||
|
right=True,
|
||||||
|
left_title="Explorer", # Custom title for left panel
|
||||||
|
right_title="Properties" # Custom title for right panel
|
||||||
|
)
|
||||||
|
panel = Panel(parent=root_instance, conf=conf)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disabling panel titles:**
|
||||||
|
|
||||||
|
When titles are disabled, panels use the legacy layout without a sticky header:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Disable titles (legacy layout)
|
||||||
|
conf = PanelConf(
|
||||||
|
left=True,
|
||||||
|
right=True,
|
||||||
|
show_left_title=False,
|
||||||
|
show_right_title=False
|
||||||
|
)
|
||||||
|
panel = Panel(parent=root_instance, conf=conf)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disabling show icons:**
|
||||||
|
|
||||||
|
You can hide the show icons (⋯) that appear when panels are hidden. This means users can only show panels programmatically:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Disable show icons (programmatic control only)
|
||||||
|
conf = PanelConf(
|
||||||
|
left=True,
|
||||||
|
right=True,
|
||||||
|
show_display_left=False, # No show icon for left panel
|
||||||
|
show_display_right=False # No show icon for right panel
|
||||||
|
)
|
||||||
|
panel = Panel(parent=root_instance, conf=conf)
|
||||||
|
```
|
||||||
|
|
||||||
**Note:** When a panel is disabled in configuration, it won't render at all. When a panel is hidden (via toggle), it
|
**Note:** When a panel is disabled in configuration, it won't render at all. When a panel is hidden (via toggle), it
|
||||||
renders but with zero width and overflow hidden.
|
renders but with zero width and overflow hidden.
|
||||||
|
|
||||||
@@ -321,8 +392,8 @@ You can control panels programmatically using commands:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# Toggle panel visibility
|
# Toggle panel visibility
|
||||||
toggle_left = panel.commands.toggle_side("left", visible=False) # Hide left
|
toggle_left = panel.commands.set_side_visible("left", visible=False) # Hide left
|
||||||
toggle_right = panel.commands.toggle_side("right", visible=True) # Show right
|
toggle_right = panel.commands.set_side_visible("right", visible=True) # Show right
|
||||||
|
|
||||||
# Update panel width
|
# Update panel width
|
||||||
update_left_width = panel.commands.update_side_width("left")
|
update_left_width = panel.commands.update_side_width("left")
|
||||||
@@ -335,8 +406,8 @@ These commands are typically used with buttons or other interactive elements:
|
|||||||
from myfasthtml.controls.helpers import mk
|
from myfasthtml.controls.helpers import mk
|
||||||
|
|
||||||
# Add buttons to toggle panels
|
# Add buttons to toggle panels
|
||||||
hide_left_btn = mk.button("Hide Left", command=panel.commands.toggle_side("left", False))
|
hide_left_btn = mk.button("Hide Left", command=panel.commands.set_side_visible("left", False))
|
||||||
show_left_btn = mk.button("Show Left", command=panel.commands.toggle_side("left", True))
|
show_left_btn = mk.button("Show Left", command=panel.commands.set_side_visible("left", True))
|
||||||
|
|
||||||
# Add to your layout
|
# Add to your layout
|
||||||
panel.set_main(
|
panel.set_main(
|
||||||
@@ -365,11 +436,15 @@ panel.set_main(
|
|||||||
The Panel uses CSS classes that you can customize:
|
The Panel uses CSS classes that you can customize:
|
||||||
|
|
||||||
| Class | Element |
|
| Class | Element |
|
||||||
|----------------------------|------------------------------------------|
|
|----------------------------|--------------------------------------------|
|
||||||
| `mf-panel` | Root panel container |
|
| `mf-panel` | Root panel container |
|
||||||
| `mf-panel-left` | Left panel container |
|
| `mf-panel-left` | Left panel container |
|
||||||
| `mf-panel-right` | Right panel container |
|
| `mf-panel-right` | Right panel container |
|
||||||
| `mf-panel-main` | Main content area |
|
| `mf-panel-main` | Main content area |
|
||||||
|
| `mf-panel-with-title` | Panel using title layout (no padding-top) |
|
||||||
|
| `mf-panel-body` | Grid container for header + content |
|
||||||
|
| `mf-panel-header` | Sticky header with title and hide icon |
|
||||||
|
| `mf-panel-content` | Scrollable content area |
|
||||||
| `mf-panel-hide-icon` | Hide icon (−) inside panels |
|
| `mf-panel-hide-icon` | Hide icon (−) inside panels |
|
||||||
| `mf-panel-show-icon` | Show icon (⋯) in main area |
|
| `mf-panel-show-icon` | Show icon (⋯) in main area |
|
||||||
| `mf-panel-show-icon-left` | Show icon for left panel |
|
| `mf-panel-show-icon-left` | Show icon for left panel |
|
||||||
@@ -641,13 +716,13 @@ panel.set_right(
|
|||||||
# Create control buttons
|
# Create control buttons
|
||||||
toggle_left_btn = mk.button(
|
toggle_left_btn = mk.button(
|
||||||
"Toggle Left Panel",
|
"Toggle Left Panel",
|
||||||
command=panel.commands.toggle_side("left", False),
|
command=panel.commands.set_side_visible("left", False),
|
||||||
cls="btn btn-sm"
|
cls="btn btn-sm"
|
||||||
)
|
)
|
||||||
|
|
||||||
toggle_right_btn = mk.button(
|
toggle_right_btn = mk.button(
|
||||||
"Toggle Right Panel",
|
"Toggle Right Panel",
|
||||||
command=panel.commands.toggle_side("right", False),
|
command=panel.commands.set_side_visible("right", False),
|
||||||
cls="btn btn-sm"
|
cls="btn btn-sm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -697,9 +772,15 @@ This section contains technical details for developers working on the Panel comp
|
|||||||
The Panel component uses `PanelConf` dataclass for configuration:
|
The Panel component uses `PanelConf` dataclass for configuration:
|
||||||
|
|
||||||
| Property | Type | Description | Default |
|
| Property | Type | Description | Default |
|
||||||
|----------|---------|----------------------------|---------|
|
|----------------------|---------|-------------------------------------------|-----------|
|
||||||
| `left` | boolean | Enable/disable left panel | `True` |
|
| `left` | boolean | Enable/disable left panel | `False` |
|
||||||
| `right` | boolean | Enable/disable right panel | `True` |
|
| `right` | boolean | Enable/disable right panel | `True` |
|
||||||
|
| `left_title` | string | Title displayed in left panel header | `"Left"` |
|
||||||
|
| `right_title` | string | Title displayed in right panel header | `"Right"` |
|
||||||
|
| `show_left_title` | boolean | Show title header on left panel | `True` |
|
||||||
|
| `show_right_title` | boolean | Show title header on right panel | `True` |
|
||||||
|
| `show_display_left` | boolean | Show the "show" icon when left is hidden | `True` |
|
||||||
|
| `show_display_right` | boolean | Show the "show" icon when right is hidden | `True` |
|
||||||
|
|
||||||
### State
|
### State
|
||||||
|
|
||||||
@@ -735,10 +816,40 @@ codebase.
|
|||||||
|
|
||||||
### High Level Hierarchical Structure
|
### High Level Hierarchical Structure
|
||||||
|
|
||||||
|
**With title (default, `show_*_title=True`):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Div(id="{id}", cls="mf-panel")
|
||||||
|
├── Div(id="{id}_pl", cls="mf-panel-left mf-panel-with-title [mf-hidden]")
|
||||||
|
│ ├── Div(cls="mf-panel-body")
|
||||||
|
│ │ ├── Div(cls="mf-panel-header")
|
||||||
|
│ │ │ ├── Div [Title text]
|
||||||
|
│ │ │ └── Div (hide icon)
|
||||||
|
│ │ └── Div(id="{id}_cl", cls="mf-panel-content")
|
||||||
|
│ │ └── [Left content - scrollable]
|
||||||
|
│ └── Div (resizer-left)
|
||||||
|
├── Div(cls="mf-panel-main")
|
||||||
|
│ ├── Div(id="{id}_show_left", cls="hidden|mf-panel-show-icon-left")
|
||||||
|
│ ├── Div(id="{id}_m", cls="mf-panel-main")
|
||||||
|
│ │ └── [Main content]
|
||||||
|
│ └── Div(id="{id}_show_right", cls="hidden|mf-panel-show-icon-right")
|
||||||
|
├── Div(id="{id}_pr", cls="mf-panel-right mf-panel-with-title [mf-hidden]")
|
||||||
|
│ ├── Div (resizer-right)
|
||||||
|
│ └── Div(cls="mf-panel-body")
|
||||||
|
│ ├── Div(cls="mf-panel-header")
|
||||||
|
│ │ ├── Div [Title text]
|
||||||
|
│ │ └── Div (hide icon)
|
||||||
|
│ └── Div(id="{id}_cr", cls="mf-panel-content")
|
||||||
|
│ └── [Right content - scrollable]
|
||||||
|
└── Script # initResizer('{id}')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Without title (legacy, `show_*_title=False`):**
|
||||||
|
|
||||||
```
|
```
|
||||||
Div(id="{id}", cls="mf-panel")
|
Div(id="{id}", cls="mf-panel")
|
||||||
├── Div(id="{id}_pl", cls="mf-panel-left [mf-hidden]")
|
├── Div(id="{id}_pl", cls="mf-panel-left [mf-hidden]")
|
||||||
│ ├── Div (hide icon)
|
│ ├── Div (hide icon - absolute positioned)
|
||||||
│ ├── Div(id="{id}_cl")
|
│ ├── Div(id="{id}_cl")
|
||||||
│ │ └── [Left content]
|
│ │ └── [Left content]
|
||||||
│ └── Div (resizer-left)
|
│ └── Div (resizer-left)
|
||||||
@@ -749,7 +860,7 @@ Div(id="{id}", cls="mf-panel")
|
|||||||
│ └── Div(id="{id}_show_right", cls="hidden|mf-panel-show-icon-right")
|
│ └── Div(id="{id}_show_right", cls="hidden|mf-panel-show-icon-right")
|
||||||
├── Div(id="{id}_pr", cls="mf-panel-right [mf-hidden]")
|
├── Div(id="{id}_pr", cls="mf-panel-right [mf-hidden]")
|
||||||
│ ├── Div (resizer-right)
|
│ ├── Div (resizer-right)
|
||||||
│ ├── Div (hide icon)
|
│ ├── Div (hide icon - absolute positioned)
|
||||||
│ └── Div(id="{id}_cr")
|
│ └── Div(id="{id}_cr")
|
||||||
│ └── [Right content]
|
│ └── [Right content]
|
||||||
└── Script # initResizer('{id}')
|
└── Script # initResizer('{id}')
|
||||||
@@ -757,11 +868,12 @@ Div(id="{id}", cls="mf-panel")
|
|||||||
|
|
||||||
**Note:**
|
**Note:**
|
||||||
|
|
||||||
- Left panel: hide icon, then content, then resizer (resizer on right edge)
|
- With title: uses grid layout (`mf-panel-body`) with sticky header and scrollable content
|
||||||
- Right panel: resizer, then hide icon, then content (resizer on left edge)
|
- Without title: hide icon is absolutely positioned at top-right with padding-top on panel
|
||||||
- Hide icons are positioned at panel root level (not inside content div)
|
- Left panel: body/content then resizer (resizer on right edge)
|
||||||
- Main content has an outer wrapper and inner content div with ID
|
- Right panel: resizer then body/content (resizer on left edge)
|
||||||
- `[mf-hidden]` class is conditionally applied when panel is hidden
|
- `[mf-hidden]` class is conditionally applied when panel is hidden
|
||||||
|
- `mf-panel-with-title` class removes default padding-top when using title layout
|
||||||
|
|
||||||
### Element IDs
|
### Element IDs
|
||||||
|
|
||||||
|
|||||||
@@ -459,7 +459,7 @@
|
|||||||
|
|
||||||
.mf-search-results {
|
.mf-search-results {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
max-height: 200px;
|
/*max-height: 400px;*/
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -769,6 +769,38 @@
|
|||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Panel with title - grid layout for header + scrollable content */
|
||||||
|
.mf-panel-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background-color: var(--color-base-200);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override absolute positioning for hide icon when inside header */
|
||||||
|
.mf-panel-header .mf-panel-hide-icon {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mf-panel-content {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove padding-top when using title layout */
|
||||||
|
.mf-panel-left.mf-panel-with-title,
|
||||||
|
.mf-panel-right.mf-panel-with-title {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* *********************************************** */
|
/* *********************************************** */
|
||||||
/* ************* Properties Component ************ */
|
/* ************* Properties Component ************ */
|
||||||
/* *********************************************** */
|
/* *********************************************** */
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from myfasthtml.controls.CycleStateControl import CycleStateControl
|
|||||||
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
|
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
|
||||||
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
|
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
|
||||||
from myfasthtml.controls.Mouse import Mouse
|
from myfasthtml.controls.Mouse import Mouse
|
||||||
|
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
||||||
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
||||||
from myfasthtml.controls.helpers import mk
|
from myfasthtml.controls.helpers import mk
|
||||||
@@ -25,6 +26,7 @@ from myfasthtml.core.optimized_ft import OptimizedDiv
|
|||||||
from myfasthtml.core.utils import make_safe_id
|
from myfasthtml.core.utils import make_safe_id
|
||||||
from myfasthtml.icons.carbon import row, column, grid
|
from myfasthtml.icons.carbon import row, column, grid
|
||||||
from myfasthtml.icons.fluent import checkbox_unchecked16_regular
|
from myfasthtml.icons.fluent import checkbox_unchecked16_regular
|
||||||
|
from myfasthtml.icons.fluent_p1 import settings16_regular
|
||||||
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular
|
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular
|
||||||
|
|
||||||
# OPTIMIZATION: Pre-compiled regex to detect HTML special characters
|
# OPTIMIZATION: Pre-compiled regex to detect HTML special characters
|
||||||
@@ -129,6 +131,13 @@ class Commands(BaseCommands):
|
|||||||
self._owner.on_click
|
self._owner.on_click
|
||||||
).htmx(target=f"#tsm_{self._id}")
|
).htmx(target=f"#tsm_{self._id}")
|
||||||
|
|
||||||
|
def toggle_columns_manager(self):
|
||||||
|
return Command("ToggleColumnsManager",
|
||||||
|
"Toggle Columns Manager",
|
||||||
|
self._owner,
|
||||||
|
self._owner.toggle_columns_manager
|
||||||
|
).htmx(target=None)
|
||||||
|
|
||||||
|
|
||||||
class DataGrid(MultipleInstance):
|
class DataGrid(MultipleInstance):
|
||||||
def __init__(self, parent, settings=None, save_state=None, _id=None):
|
def __init__(self, parent, settings=None, save_state=None, _id=None):
|
||||||
@@ -138,6 +147,11 @@ class DataGrid(MultipleInstance):
|
|||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
|
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
|
||||||
|
|
||||||
|
# add Panel
|
||||||
|
self._panel = Panel(self, conf=PanelConf(right_title="Columns", show_display_right=False), _id="-panel")
|
||||||
|
self._panel.set_side_visible("right", False) # the right Panel always starts closed
|
||||||
|
self.bind_command("ToggleColumnsManager", self._panel.commands.toggle_side("right"))
|
||||||
|
|
||||||
# add DataGridQuery
|
# add DataGridQuery
|
||||||
self._datagrid_filter = DataGridQuery(self)
|
self._datagrid_filter = DataGridQuery(self)
|
||||||
self._datagrid_filter.bind_command("QueryChanged", self.commands.filter())
|
self._datagrid_filter.bind_command("QueryChanged", self.commands.filter())
|
||||||
@@ -359,6 +373,10 @@ class DataGrid(MultipleInstance):
|
|||||||
self._state.save()
|
self._state.save()
|
||||||
return self.render_partial()
|
return self.render_partial()
|
||||||
|
|
||||||
|
def toggle_columns_manager(self):
|
||||||
|
logger.debug(f"toggle_columns_manager")
|
||||||
|
self._panel.set_right(self._columns_manager)
|
||||||
|
|
||||||
def mk_headers(self):
|
def mk_headers(self):
|
||||||
resize_cmd = self.commands.set_column_width()
|
resize_cmd = self.commands.set_column_width()
|
||||||
move_cmd = self.commands.move_column()
|
move_cmd = self.commands.move_column()
|
||||||
@@ -646,10 +664,10 @@ class DataGrid(MultipleInstance):
|
|||||||
Div(self._datagrid_filter,
|
Div(self._datagrid_filter,
|
||||||
Div(
|
Div(
|
||||||
self._selection_mode_selector,
|
self._selection_mode_selector,
|
||||||
self._columns_manager,
|
mk.icon(settings16_regular, command=self.commands.toggle_columns_manager(), tooltip="Show sidebar"),
|
||||||
cls="flex"),
|
cls="flex"),
|
||||||
cls="flex items-center justify-between mb-2"),
|
cls="flex items-center justify-between mb-2"),
|
||||||
self.mk_table(),
|
self._panel.set_main(self.mk_table()),
|
||||||
Script(f"initDataGrid('{self._id}');"),
|
Script(f"initDataGrid('{self._id}');"),
|
||||||
Mouse(self, combinations=self._mouse_support),
|
Mouse(self, combinations=self._mouse_support),
|
||||||
id=self._id,
|
id=self._id,
|
||||||
|
|||||||
@@ -1,15 +1,33 @@
|
|||||||
from fasthtml.components import Div
|
from fasthtml.components import *
|
||||||
|
|
||||||
from myfasthtml.controls.Dropdown import Dropdown
|
from myfasthtml.controls.Search import Search
|
||||||
from myfasthtml.controls.helpers import mk
|
from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
||||||
from myfasthtml.icons.fluent_p1 import settings16_regular
|
from myfasthtml.core.instances import MultipleInstance
|
||||||
|
|
||||||
|
|
||||||
class DataGridColumnsManager(Dropdown):
|
class DataGridColumnsManager(MultipleInstance):
|
||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(parent, _id=_id, align="right")
|
super().__init__(parent, _id=_id)
|
||||||
self.button = mk.icon(settings16_regular)
|
|
||||||
self.content = Div("DataGridColumnsManager")
|
|
||||||
|
|
||||||
|
@property
|
||||||
def columns(self):
|
def columns(self):
|
||||||
return self._parent._state.columns
|
return self._parent._state.columns
|
||||||
|
|
||||||
|
def mk_column(self, col_def: DataGridColumnState):
|
||||||
|
return Div(
|
||||||
|
Input(type="checkbox", checked=col_def.visible, cls="ml-2"),
|
||||||
|
Label(col_def.col_id, cls="ml-2"),
|
||||||
|
cls="flex mb-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
return Search(self,
|
||||||
|
items_names="Columns",
|
||||||
|
items=self.columns,
|
||||||
|
get_attr=lambda x: x.col_id,
|
||||||
|
template=self.mk_column,
|
||||||
|
max_height=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def __ft__(self):
|
||||||
|
return self.render()
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from fasthtml.components import Div
|
|||||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.DataGrid import DataGrid
|
from myfasthtml.controls.DataGrid import DataGrid
|
||||||
from myfasthtml.controls.FileUpload import FileUpload
|
from myfasthtml.controls.FileUpload import FileUpload
|
||||||
from myfasthtml.controls.Panel import Panel
|
|
||||||
from myfasthtml.controls.TabsManager import TabsManager
|
from myfasthtml.controls.TabsManager import TabsManager
|
||||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||||
from myfasthtml.controls.helpers import mk
|
from myfasthtml.controls.helpers import mk
|
||||||
@@ -106,7 +105,7 @@ class DataGridsManager(MultipleInstance):
|
|||||||
parent_id = self._tree.ensure_path(document.namespace)
|
parent_id = self._tree.ensure_path(document.namespace)
|
||||||
tree_node = TreeNode(label=document.name, type="excel", parent=parent_id)
|
tree_node = TreeNode(label=document.name, type="excel", parent=parent_id)
|
||||||
self._tree.add_node(tree_node, parent_id=parent_id)
|
self._tree.add_node(tree_node, parent_id=parent_id)
|
||||||
return self._mk_tree(), self._tabs_manager.change_tab_content(tab_id, document.name, Panel(self).set_main(dg))
|
return self._mk_tree(), self._tabs_manager.change_tab_content(tab_id, document.name, dg)
|
||||||
|
|
||||||
def select_document(self, node_id):
|
def select_document(self, node_id):
|
||||||
document_id = self._tree.get_bag(node_id)
|
document_id = self._tree.get_bag(node_id)
|
||||||
@@ -137,9 +136,7 @@ class DataGridsManager(MultipleInstance):
|
|||||||
|
|
||||||
# Recreate the DataGrid with its saved state
|
# Recreate the DataGrid with its saved state
|
||||||
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) # reload the state & settings
|
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) # reload the state & settings
|
||||||
|
return dg
|
||||||
# Wrap in Panel
|
|
||||||
return Panel(self).set_main(dg)
|
|
||||||
|
|
||||||
def clear_tree(self):
|
def clear_tree(self):
|
||||||
self._state.elements = []
|
self._state.elements = []
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from fasthtml.components import Div
|
from fasthtml.components import Div
|
||||||
from fasthtml.xtend import Script
|
from fasthtml.xtend import Script
|
||||||
@@ -42,6 +42,12 @@ class PanelIds:
|
|||||||
class PanelConf:
|
class PanelConf:
|
||||||
left: bool = False
|
left: bool = False
|
||||||
right: bool = True
|
right: bool = True
|
||||||
|
left_title: str = "Left"
|
||||||
|
right_title: str = "Right"
|
||||||
|
show_left_title: bool = True
|
||||||
|
show_right_title: bool = True
|
||||||
|
show_display_left: bool = True
|
||||||
|
show_display_right: bool = True
|
||||||
|
|
||||||
|
|
||||||
class PanelState(DbObject):
|
class PanelState(DbObject):
|
||||||
@@ -55,12 +61,19 @@ class PanelState(DbObject):
|
|||||||
|
|
||||||
|
|
||||||
class Commands(BaseCommands):
|
class Commands(BaseCommands):
|
||||||
def toggle_side(self, side: Literal["left", "right"], visible: bool = None):
|
def set_side_visible(self, side: Literal["left", "right"], visible: bool = None):
|
||||||
|
return Command("TogglePanelSide",
|
||||||
|
f"Toggle {side} side panel",
|
||||||
|
self._owner,
|
||||||
|
self._owner.set_side_visible,
|
||||||
|
args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||||
|
|
||||||
|
def toggle_side(self, side: Literal["left", "right"]):
|
||||||
return Command("TogglePanelSide",
|
return Command("TogglePanelSide",
|
||||||
f"Toggle {side} side panel",
|
f"Toggle {side} side panel",
|
||||||
self._owner,
|
self._owner,
|
||||||
self._owner.toggle_side,
|
self._owner.toggle_side,
|
||||||
args=[side, visible]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
args=[side]).htmx(target=f"#{self._owner.get_ids().panel(side)}")
|
||||||
|
|
||||||
def update_side_width(self, side: Literal["left", "right"]):
|
def update_side_width(self, side: Literal["left", "right"]):
|
||||||
"""
|
"""
|
||||||
@@ -89,7 +102,7 @@ class Panel(MultipleInstance):
|
|||||||
the panel with appropriate HTML elements and JavaScript for interactivity.
|
the panel with appropriate HTML elements and JavaScript for interactivity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, parent, conf=None, _id=None):
|
def __init__(self, parent, conf: Optional[PanelConf] = None, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
self.conf = conf or PanelConf()
|
self.conf = conf or PanelConf()
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
@@ -110,7 +123,7 @@ class Panel(MultipleInstance):
|
|||||||
|
|
||||||
return self._mk_panel(side)
|
return self._mk_panel(side)
|
||||||
|
|
||||||
def toggle_side(self, side, visible):
|
def set_side_visible(self, side, visible):
|
||||||
if side == "left":
|
if side == "left":
|
||||||
self._state.left_visible = visible
|
self._state.left_visible = visible
|
||||||
else:
|
else:
|
||||||
@@ -118,6 +131,10 @@ class Panel(MultipleInstance):
|
|||||||
|
|
||||||
return self._mk_panel(side), self._mk_show_icon(side)
|
return self._mk_panel(side), self._mk_show_icon(side)
|
||||||
|
|
||||||
|
def toggle_side(self, side):
|
||||||
|
current_visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||||
|
return self.set_side_visible(side, not current_visible)
|
||||||
|
|
||||||
def set_main(self, main):
|
def set_main(self, main):
|
||||||
self._main = main
|
self._main = main
|
||||||
return self
|
return self
|
||||||
@@ -137,6 +154,8 @@ class Panel(MultipleInstance):
|
|||||||
|
|
||||||
visible = self._state.left_visible if side == "left" else self._state.right_visible
|
visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||||
content = self._right if side == "right" else self._left
|
content = self._right if side == "right" else self._left
|
||||||
|
show_title = self.conf.show_left_title if side == "left" else self.conf.show_right_title
|
||||||
|
title = self.conf.left_title if side == "left" else self.conf.right_title
|
||||||
|
|
||||||
resizer = Div(
|
resizer = Div(
|
||||||
cls=f"mf-resizer mf-resizer-{side}",
|
cls=f"mf-resizer mf-resizer-{side}",
|
||||||
@@ -147,16 +166,44 @@ class Panel(MultipleInstance):
|
|||||||
hide_icon = mk.icon(
|
hide_icon = mk.icon(
|
||||||
subtract20_regular,
|
subtract20_regular,
|
||||||
size=20,
|
size=20,
|
||||||
command=self.commands.toggle_side(side, False),
|
command=self.commands.set_side_visible(side, False),
|
||||||
cls="mf-panel-hide-icon"
|
cls="mf-panel-hide-icon"
|
||||||
)
|
)
|
||||||
|
|
||||||
panel_cls = f"mf-panel-{side}"
|
panel_cls = f"mf-panel-{side}"
|
||||||
if not visible:
|
if not visible:
|
||||||
panel_cls += " mf-hidden"
|
panel_cls += " mf-hidden"
|
||||||
|
if show_title:
|
||||||
|
panel_cls += " mf-panel-with-title"
|
||||||
|
|
||||||
# Left panel: content then resizer (resizer on the right)
|
# Left panel: content then resizer (resizer on the right)
|
||||||
# Right panel: resizer then content (resizer on the left)
|
# Right panel: resizer then content (resizer on the left)
|
||||||
|
if show_title:
|
||||||
|
header = Div(
|
||||||
|
Div(title),
|
||||||
|
hide_icon,
|
||||||
|
cls="mf-panel-header"
|
||||||
|
)
|
||||||
|
body = Div(
|
||||||
|
header,
|
||||||
|
Div(content, id=self._ids.content(side), cls="mf-panel-content"),
|
||||||
|
cls="mf-panel-body"
|
||||||
|
)
|
||||||
|
if side == "left":
|
||||||
|
return Div(
|
||||||
|
body,
|
||||||
|
resizer,
|
||||||
|
cls=panel_cls,
|
||||||
|
id=self._ids.panel(side)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Div(
|
||||||
|
resizer,
|
||||||
|
body,
|
||||||
|
cls=panel_cls,
|
||||||
|
id=self._ids.panel(side)
|
||||||
|
)
|
||||||
|
else:
|
||||||
if side == "left":
|
if side == "left":
|
||||||
return Div(
|
return Div(
|
||||||
hide_icon,
|
hide_icon,
|
||||||
@@ -196,12 +243,16 @@ class Panel(MultipleInstance):
|
|||||||
if not enabled:
|
if not enabled:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
show_display = self.conf.show_display_left if side == "left" else self.conf.show_display_right
|
||||||
|
if not show_display:
|
||||||
|
return None
|
||||||
|
|
||||||
is_visible = self._state.left_visible if side == "left" else self._state.right_visible
|
is_visible = self._state.left_visible if side == "left" else self._state.right_visible
|
||||||
icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}"
|
icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}"
|
||||||
|
|
||||||
return mk.icon(
|
return mk.icon(
|
||||||
more_horizontal20_regular,
|
more_horizontal20_regular,
|
||||||
command=self.commands.toggle_side(side, True),
|
command=self.commands.set_side_visible(side, True),
|
||||||
cls=icon_cls,
|
cls=icon_cls,
|
||||||
id=f"{self._id}_show_{side}"
|
id=f"{self._id}_show_{side}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ class Search(MultipleInstance):
|
|||||||
items_names=None, # what is the name of the items to filter
|
items_names=None, # what is the name of the items to filter
|
||||||
items=None, # first set of items to filter
|
items=None, # first set of items to filter
|
||||||
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
|
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
|
||||||
template: Callable[[Any], Any] = None): # once filtered, what to render ?
|
template: Callable[[Any], Any] = None, # once filtered, what to render ?
|
||||||
|
max_height: int = 400):
|
||||||
"""
|
"""
|
||||||
Represents a component for managing and filtering a list of items based on specific criteria.
|
Represents a component for managing and filtering a list of items based on specific criteria.
|
||||||
|
|
||||||
@@ -64,8 +65,9 @@ class Search(MultipleInstance):
|
|||||||
self.items = items or []
|
self.items = items or []
|
||||||
self.filtered = self.items.copy()
|
self.filtered = self.items.copy()
|
||||||
self.get_attr = get_attr or (lambda x: x)
|
self.get_attr = get_attr or (lambda x: x)
|
||||||
self.template = template or Div
|
self.template = template or (lambda x: Div(self.get_attr(x)))
|
||||||
self.commands = Commands(self)
|
self.commands = Commands(self)
|
||||||
|
self.max_height = max_height
|
||||||
|
|
||||||
def set_items(self, items):
|
def set_items(self, items):
|
||||||
self.items = items
|
self.items = items
|
||||||
@@ -106,6 +108,7 @@ class Search(MultipleInstance):
|
|||||||
*self._mk_search_results(),
|
*self._mk_search_results(),
|
||||||
id=f"{self._id}-results",
|
id=f"{self._id}-results",
|
||||||
cls="mf-search-results",
|
cls="mf-search-results",
|
||||||
|
style="max-height: 400px;" if self.max_height else None
|
||||||
),
|
),
|
||||||
id=f"{self._id}",
|
id=f"{self._id}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class BaseInstance:
|
|||||||
return _id
|
return _id
|
||||||
|
|
||||||
if _id.startswith("-") and parent is not None:
|
if _id.startswith("-") and parent is not None:
|
||||||
return f"{parent.get_prefix()}{_id}"
|
return f"{parent.get_id()}{_id}"
|
||||||
|
|
||||||
return _id
|
return _id
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class TestPanelBehaviour:
|
|||||||
"""Test that toggle_side('left', False) sets _state.left_visible to False."""
|
"""Test that toggle_side('left', False) sets _state.left_visible to False."""
|
||||||
panel = Panel(root_instance)
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
panel.toggle_side("left", False)
|
panel.set_side_visible("left", False)
|
||||||
|
|
||||||
assert panel._state.left_visible is False
|
assert panel._state.left_visible is False
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ class TestPanelBehaviour:
|
|||||||
panel = Panel(root_instance)
|
panel = Panel(root_instance)
|
||||||
panel._state.left_visible = False
|
panel._state.left_visible = False
|
||||||
|
|
||||||
panel.toggle_side("left", True)
|
panel.set_side_visible("left", True)
|
||||||
|
|
||||||
assert panel._state.left_visible is True
|
assert panel._state.left_visible is True
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ class TestPanelBehaviour:
|
|||||||
"""Test that toggle_side('right', False) sets _state.right_visible to False."""
|
"""Test that toggle_side('right', False) sets _state.right_visible to False."""
|
||||||
panel = Panel(root_instance)
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
panel.toggle_side("right", False)
|
panel.set_side_visible("right", False)
|
||||||
|
|
||||||
assert panel._state.right_visible is False
|
assert panel._state.right_visible is False
|
||||||
|
|
||||||
@@ -122,15 +122,45 @@ class TestPanelBehaviour:
|
|||||||
panel = Panel(root_instance)
|
panel = Panel(root_instance)
|
||||||
panel._state.right_visible = False
|
panel._state.right_visible = False
|
||||||
|
|
||||||
panel.toggle_side("right", True)
|
panel.set_side_visible("right", True)
|
||||||
|
|
||||||
assert panel._state.right_visible is True
|
assert panel._state.right_visible is True
|
||||||
|
|
||||||
|
def test_set_side_visible_returns_panel_and_icon(self, root_instance):
|
||||||
|
"""Test that set_side_visible() returns a tuple (panel_element, show_icon_element)."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
|
result = panel.set_side_visible("left", False)
|
||||||
|
|
||||||
|
assert isinstance(result, tuple)
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("side, initial_visible, expected_visible", [
|
||||||
|
("left", True, False), # left visible → hidden
|
||||||
|
("left", False, True), # left hidden → visible
|
||||||
|
("right", True, False), # right visible → hidden
|
||||||
|
("right", False, True), # right hidden → visible
|
||||||
|
])
|
||||||
|
def test_i_can_toggle_panel_visibility(self, root_instance, side, initial_visible, expected_visible):
|
||||||
|
"""Test that toggle_side() inverts the visibility state."""
|
||||||
|
panel = Panel(root_instance)
|
||||||
|
if side == "left":
|
||||||
|
panel._state.left_visible = initial_visible
|
||||||
|
else:
|
||||||
|
panel._state.right_visible = initial_visible
|
||||||
|
|
||||||
|
panel.toggle_side(side)
|
||||||
|
|
||||||
|
if side == "left":
|
||||||
|
assert panel._state.left_visible is expected_visible
|
||||||
|
else:
|
||||||
|
assert panel._state.right_visible is expected_visible
|
||||||
|
|
||||||
def test_toggle_side_returns_panel_and_icon(self, root_instance):
|
def test_toggle_side_returns_panel_and_icon(self, root_instance):
|
||||||
"""Test that toggle_side() returns a tuple (panel_element, show_icon_element)."""
|
"""Test that toggle_side() returns a tuple (panel_element, show_icon_element)."""
|
||||||
panel = Panel(root_instance)
|
panel = Panel(root_instance)
|
||||||
|
|
||||||
result = panel.toggle_side("left", False)
|
result = panel.toggle_side("left")
|
||||||
|
|
||||||
assert isinstance(result, tuple)
|
assert isinstance(result, tuple)
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
@@ -190,13 +220,39 @@ class TestPanelBehaviour:
|
|||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("side, conf_kwargs", [
|
||||||
|
("left", {"show_display_left": False}),
|
||||||
|
("right", {"show_display_right": False}),
|
||||||
|
])
|
||||||
|
def test_show_icon_returns_none_when_show_display_disabled(self, root_instance, side, conf_kwargs):
|
||||||
|
"""Test that _mk_show_icon() returns None when show_display is disabled."""
|
||||||
|
custom_conf = PanelConf(left=True, right=True, **conf_kwargs)
|
||||||
|
panel = Panel(root_instance, conf=custom_conf)
|
||||||
|
|
||||||
|
result = panel._mk_show_icon(side)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
class TestPanelRender:
|
class TestPanelRender:
|
||||||
"""Tests for Panel HTML rendering."""
|
"""Tests for Panel HTML rendering."""
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def panel(self, root_instance):
|
def panel(self, root_instance):
|
||||||
panel = Panel(root_instance, PanelConf(True, True))
|
"""Panel with titles (default behavior)."""
|
||||||
|
panel = Panel(root_instance, PanelConf(left=True, right=True))
|
||||||
|
panel.set_main(Div("Main content"))
|
||||||
|
panel.set_left(Div("Left content"))
|
||||||
|
panel.set_right(Div("Right content"))
|
||||||
|
return panel
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def panel_no_title(self, root_instance):
|
||||||
|
"""Panel without titles (legacy behavior)."""
|
||||||
|
panel = Panel(root_instance, PanelConf(
|
||||||
|
left=True, right=True,
|
||||||
|
show_left_title=False, show_right_title=False
|
||||||
|
))
|
||||||
panel.set_main(Div("Main content"))
|
panel.set_main(Div("Main content"))
|
||||||
panel.set_left(Div("Left content"))
|
panel.set_left(Div("Left content"))
|
||||||
panel.set_right(Div("Right content"))
|
panel.set_right(Div("Right content"))
|
||||||
@@ -225,22 +281,58 @@ class TestPanelRender:
|
|||||||
|
|
||||||
# 2. Left panel
|
# 2. Left panel
|
||||||
|
|
||||||
def test_left_panel_renders_with_correct_structure(self, panel):
|
def test_left_panel_renders_with_title_structure(self, panel):
|
||||||
"""Test that left panel has content div before resizer.
|
"""Test that left panel with title has header + scrollable content.
|
||||||
|
|
||||||
Why these elements matter:
|
Why these elements matter:
|
||||||
- Order (content then resizer): Critical for positioning resizer on the right side
|
- mf-panel-body: Grid container for header + content layout
|
||||||
- id: Required for HTMX targeting during toggle/resize operations
|
- mf-panel-header: Contains title and hide icon
|
||||||
- cls Contains "mf-panel-left": CSS class for left panel styling
|
- mf-panel-content: Scrollable content area
|
||||||
|
- mf-panel-with-title: Removes default padding-top
|
||||||
"""
|
"""
|
||||||
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||||
|
|
||||||
# Step 1: Validate left panel global structure
|
|
||||||
expected = Div(
|
expected = Div(
|
||||||
TestIcon("subtract20_regular"),
|
Div(cls=Contains("mf-panel-body")), # body with header + content
|
||||||
Div(id=panel.get_ids().left), # content div, tested in detail later
|
|
||||||
Div(cls=Contains("mf-resizer-left")), # resizer
|
Div(cls=Contains("mf-resizer-left")), # resizer
|
||||||
id=panel.get_ids().panel("left"),
|
id=panel.get_ids().panel("left"),
|
||||||
|
cls=Contains("mf-panel-left", "mf-panel-with-title")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(left_panel, expected)
|
||||||
|
|
||||||
|
def test_left_panel_header_contains_title_and_icon(self, panel):
|
||||||
|
"""Test that left panel header has title and hide icon.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Title: Displays the panel title (left aligned)
|
||||||
|
- Hide icon: Allows user to collapse the panel (right aligned)
|
||||||
|
"""
|
||||||
|
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||||
|
header = find_one(left_panel, Div(cls=Contains("mf-panel-header")))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
Div("Left"), # title
|
||||||
|
Div(cls=Contains("mf-panel-hide-icon")), # hide icon
|
||||||
|
cls="mf-panel-header"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(header, expected)
|
||||||
|
|
||||||
|
def test_left_panel_renders_without_title_structure(self, panel_no_title):
|
||||||
|
"""Test that left panel without title has legacy structure.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Order (hide icon, content, resizer): Legacy layout without header
|
||||||
|
- No mf-panel-with-title class
|
||||||
|
"""
|
||||||
|
left_panel = find_one(panel_no_title.render(), Div(id=panel_no_title.get_ids().panel("left")))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
TestIcon("subtract20_regular"),
|
||||||
|
Div(id=panel_no_title.get_ids().left),
|
||||||
|
Div(cls=Contains("mf-resizer-left")),
|
||||||
|
id=panel_no_title.get_ids().panel("left"),
|
||||||
cls=Contains("mf-panel-left")
|
cls=Contains("mf-panel-left")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -274,22 +366,58 @@ class TestPanelRender:
|
|||||||
|
|
||||||
# 3. Right panel
|
# 3. Right panel
|
||||||
|
|
||||||
def test_right_panel_renders_with_correct_structure(self, panel):
|
def test_right_panel_renders_with_title_structure(self, panel):
|
||||||
"""Test that right panel has resizer before content div.
|
"""Test that right panel with title has header + scrollable content.
|
||||||
|
|
||||||
Why these elements matter:
|
Why these elements matter:
|
||||||
- Order (resizer then hide icon then content): Critical for positioning resizer on the left side
|
- mf-panel-body: Grid container for header + content layout
|
||||||
- id: Required for HTMX targeting during toggle/resize operations
|
- mf-panel-header: Contains title and hide icon
|
||||||
- cls Contains "mf-panel-right": CSS class for right panel styling
|
- mf-panel-content: Scrollable content area
|
||||||
|
- mf-panel-with-title: Removes default padding-top
|
||||||
"""
|
"""
|
||||||
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||||
|
|
||||||
# Step 1: Validate right panel global structure
|
|
||||||
expected = Div(
|
expected = Div(
|
||||||
Div(cls=Contains("mf-resizer-right")), # resizer
|
Div(cls=Contains("mf-resizer-right")), # resizer
|
||||||
TestIcon("subtract20_regular"), # hide icon
|
Div(cls=Contains("mf-panel-body")), # body with header + content
|
||||||
Div(id=panel.get_ids().right), # content div, tested in detail later
|
|
||||||
id=panel.get_ids().panel("right"),
|
id=panel.get_ids().panel("right"),
|
||||||
|
cls=Contains("mf-panel-right", "mf-panel-with-title")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(right_panel, expected)
|
||||||
|
|
||||||
|
def test_right_panel_header_contains_title_and_icon(self, panel):
|
||||||
|
"""Test that right panel header has title and hide icon.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Title: Displays the panel title (left aligned)
|
||||||
|
- Hide icon: Allows user to collapse the panel (right aligned)
|
||||||
|
"""
|
||||||
|
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||||
|
header = find_one(right_panel, Div(cls=Contains("mf-panel-header")))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
Div("Right"), # title
|
||||||
|
Div(cls=Contains("mf-panel-hide-icon")), # hide icon
|
||||||
|
cls="mf-panel-header"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert matches(header, expected)
|
||||||
|
|
||||||
|
def test_right_panel_renders_without_title_structure(self, panel_no_title):
|
||||||
|
"""Test that right panel without title has legacy structure.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Order (resizer, hide icon, content): Legacy layout without header
|
||||||
|
- No mf-panel-with-title class
|
||||||
|
"""
|
||||||
|
right_panel = find_one(panel_no_title.render(), Div(id=panel_no_title.get_ids().panel("right")))
|
||||||
|
|
||||||
|
expected = Div(
|
||||||
|
Div(cls=Contains("mf-resizer-right")),
|
||||||
|
TestIcon("subtract20_regular"),
|
||||||
|
Div(id=panel_no_title.get_ids().right),
|
||||||
|
id=panel_no_title.get_ids().panel("right"),
|
||||||
cls=Contains("mf-panel-right")
|
cls=Contains("mf-panel-right")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -367,40 +495,57 @@ class TestPanelRender:
|
|||||||
|
|
||||||
# 5. Icons
|
# 5. Icons
|
||||||
|
|
||||||
def test_hide_icon_in_left_panel_has_correct_command(self, panel):
|
def test_hide_icon_in_left_panel_header(self, panel):
|
||||||
"""Test that hide icon in left panel triggers toggle_side command.
|
"""Test that hide icon in left panel header has correct structure.
|
||||||
|
|
||||||
Why these elements matter:
|
Why these elements matter:
|
||||||
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding
|
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding
|
||||||
- cls Contains "mf-panel-hide-icon": CSS class for hide icon positioning
|
- cls Contains "mf-panel-hide-icon": CSS class for hide icon styling
|
||||||
|
- Icon is inside header when title is shown
|
||||||
"""
|
"""
|
||||||
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
left_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("left")))
|
||||||
|
header = find_one(left_panel, Div(cls=Contains("mf-panel-header")))
|
||||||
|
|
||||||
# Find the hide icon (should be wrapped by mk.icon)
|
hide_icons = find(header, Div(cls=Contains("mf-panel-hide-icon")))
|
||||||
hide_icons = find(left_panel, Div(cls=Contains("mf-panel-hide-icon")))
|
assert len(hide_icons) == 1, "Header should contain exactly one hide icon"
|
||||||
assert len(hide_icons) == 1, "Left panel should contain exactly one hide icon"
|
|
||||||
|
|
||||||
# Verify it contains the subtract icon
|
|
||||||
expected = Div(
|
expected = Div(
|
||||||
TestIconNotStr("subtract20_regular"),
|
TestIconNotStr("subtract20_regular"),
|
||||||
cls=Contains("mf-panel-hide-icon")
|
cls=Contains("mf-panel-hide-icon")
|
||||||
)
|
)
|
||||||
assert matches(hide_icons[0], expected)
|
assert matches(hide_icons[0], expected)
|
||||||
|
|
||||||
def test_hide_icon_in_right_panel_has_correct_command(self, panel):
|
def test_hide_icon_in_right_panel_header(self, panel):
|
||||||
"""Test that hide icon in right panel triggers toggle_side command.
|
"""Test that hide icon in right panel header has correct structure.
|
||||||
|
|
||||||
Why these elements matter:
|
Why these elements matter:
|
||||||
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding
|
- TestIconNotStr("subtract20_regular"): Verify correct icon is used for hiding
|
||||||
- cls Contains "mf-panel-hide-icon": CSS class for hide icon positioning
|
- cls Contains "mf-panel-hide-icon": CSS class for hide icon styling
|
||||||
|
- Icon is inside header when title is shown
|
||||||
"""
|
"""
|
||||||
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
right_panel = find_one(panel.render(), Div(id=panel.get_ids().panel("right")))
|
||||||
|
header = find_one(right_panel, Div(cls=Contains("mf-panel-header")))
|
||||||
|
|
||||||
# Find the hide icon (should be wrapped by mk.icon)
|
hide_icons = find(header, Div(cls=Contains("mf-panel-hide-icon")))
|
||||||
hide_icons = find(right_panel, Div(cls=Contains("mf-panel-hide-icon")))
|
assert len(hide_icons) == 1, "Header should contain exactly one hide icon"
|
||||||
assert len(hide_icons) == 1, "Right panel should contain exactly one hide icon"
|
|
||||||
|
expected = Div(
|
||||||
|
TestIconNotStr("subtract20_regular"),
|
||||||
|
cls=Contains("mf-panel-hide-icon")
|
||||||
|
)
|
||||||
|
assert matches(hide_icons[0], expected)
|
||||||
|
|
||||||
|
def test_hide_icon_in_panel_without_title(self, panel_no_title):
|
||||||
|
"""Test that hide icon is at root level when no title.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Hide icon should be direct child of panel (legacy behavior)
|
||||||
|
"""
|
||||||
|
left_panel = find_one(panel_no_title.render(), Div(id=panel_no_title.get_ids().panel("left")))
|
||||||
|
|
||||||
|
hide_icons = find(left_panel, Div(cls=Contains("mf-panel-hide-icon")))
|
||||||
|
assert len(hide_icons) == 1, "Panel should contain exactly one hide icon"
|
||||||
|
|
||||||
# Verify it contains the subtract icon
|
|
||||||
expected = Div(
|
expected = Div(
|
||||||
TestIconNotStr("subtract20_regular"),
|
TestIconNotStr("subtract20_regular"),
|
||||||
cls=Contains("mf-panel-hide-icon")
|
cls=Contains("mf-panel-hide-icon")
|
||||||
@@ -459,6 +604,26 @@ class TestPanelRender:
|
|||||||
|
|
||||||
assert matches(show_icon, expected)
|
assert matches(show_icon, expected)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("side, conf_kwargs", [
|
||||||
|
("left", {"show_display_left": False}),
|
||||||
|
("right", {"show_display_right": False}),
|
||||||
|
])
|
||||||
|
def test_show_icon_not_in_main_panel_when_show_display_disabled(self, root_instance, side, conf_kwargs):
|
||||||
|
"""Test that show icon is not rendered when show_display is disabled.
|
||||||
|
|
||||||
|
Why these elements matter:
|
||||||
|
- Absence of show icon: When show_display_* is False, the icon should not exist
|
||||||
|
- This prevents users from showing the panel via UI (only programmatically)
|
||||||
|
"""
|
||||||
|
custom_conf = PanelConf(left=True, right=True, **conf_kwargs)
|
||||||
|
panel = Panel(root_instance, conf=custom_conf)
|
||||||
|
panel.set_main(Div("Main content"))
|
||||||
|
|
||||||
|
rendered = panel.render()
|
||||||
|
show_icons = find(rendered, Div(id=f"{panel._id}_show_{side}"))
|
||||||
|
|
||||||
|
assert len(show_icons) == 0, f"Show icon for {side} should not be present when show_display_{side}=False"
|
||||||
|
|
||||||
# 6. Main panel
|
# 6. Main panel
|
||||||
|
|
||||||
def test_main_panel_contains_show_icons_and_content(self, panel):
|
def test_main_panel_contains_show_icons_and_content(self, panel):
|
||||||
|
|||||||
Reference in New Issue
Block a user