2 Commits

10 changed files with 676 additions and 230 deletions

View File

@@ -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
To disable a specific rule, the user can say:

View File

@@ -9,6 +9,7 @@ panels.
**Key features:**
- Three customizable zones (left panel, main content, right panel)
- Configurable panel titles with sticky headers
- Toggle visibility with hide/show icons
- Resizable panels with drag handles
- 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) |
| Main content | Always-visible central content area |
| 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 |
| 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
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
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
from myfasthtml.controls.Panel import PanelConf
@@ -218,6 +246,49 @@ conf = PanelConf(left=False, right=False)
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
renders but with zero width and overflow hidden.
@@ -321,8 +392,8 @@ You can control panels programmatically using commands:
```python
# Toggle panel visibility
toggle_left = panel.commands.toggle_side("left", visible=False) # Hide left
toggle_right = panel.commands.toggle_side("right", visible=True) # Show right
toggle_left = panel.commands.set_side_visible("left", visible=False) # Hide left
toggle_right = panel.commands.set_side_visible("right", visible=True) # Show right
# Update panel width
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
# Add buttons to toggle panels
hide_left_btn = mk.button("Hide Left", command=panel.commands.toggle_side("left", False))
show_left_btn = mk.button("Show Left", command=panel.commands.toggle_side("left", True))
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.set_side_visible("left", True))
# Add to your layout
panel.set_main(
@@ -364,21 +435,25 @@ panel.set_main(
The Panel uses CSS classes that you can customize:
| Class | Element |
|----------------------------|------------------------------------------|
| `mf-panel` | Root panel container |
| `mf-panel-left` | Left panel container |
| `mf-panel-right` | Right panel container |
| `mf-panel-main` | Main content area |
| `mf-panel-hide-icon` | Hide icon () inside panels |
| `mf-panel-show-icon` | Show icon (⋯) in main area |
| `mf-panel-show-icon-left` | Show icon for left panel |
| `mf-panel-show-icon-right` | Show icon for right panel |
| `mf-resizer` | Resize handle base class |
| `mf-resizer-left` | Left panel resize handle |
| `mf-resizer-right` | Right panel resize handle |
| `mf-hidden` | Applied to hidden panels |
| `no-transition` | Disables transition during manual resize |
| Class | Element |
|----------------------------|--------------------------------------------|
| `mf-panel` | Root panel container |
| `mf-panel-left` | Left panel container |
| `mf-panel-right` | Right panel container |
| `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-show-icon` | Show icon (⋯) in main area |
| `mf-panel-show-icon-left` | Show icon for left panel |
| `mf-panel-show-icon-right` | Show icon for right panel |
| `mf-resizer` | Resize handle base class |
| `mf-resizer-left` | Left panel resize handle |
| `mf-resizer-right` | Right panel resize handle |
| `mf-hidden` | Applied to hidden panels |
| `no-transition` | Disables transition during manual resize |
**Example customization:**
@@ -641,13 +716,13 @@ panel.set_right(
# Create control buttons
toggle_left_btn = mk.button(
"Toggle Left Panel",
command=panel.commands.toggle_side("left", False),
command=panel.commands.set_side_visible("left", False),
cls="btn btn-sm"
)
toggle_right_btn = mk.button(
"Toggle Right Panel",
command=panel.commands.toggle_side("right", False),
command=panel.commands.set_side_visible("right", False),
cls="btn btn-sm"
)
@@ -657,8 +732,8 @@ show_all_btn = mk.button(
"show_all",
"Show all panels",
lambda: (
panel.toggle_side("left", True),
panel.toggle_side("right", True)
panel.toggle_side("left", True),
panel.toggle_side("right", True)
)
),
cls="btn btn-sm btn-primary"
@@ -696,10 +771,16 @@ This section contains technical details for developers working on the Panel comp
The Panel component uses `PanelConf` dataclass for configuration:
| Property | Type | Description | Default |
|----------|---------|----------------------------|---------|
| `left` | boolean | Enable/disable left panel | `True` |
| `right` | boolean | Enable/disable right panel | `True` |
| Property | Type | Description | Default |
|----------------------|---------|-------------------------------------------|-----------|
| `left` | boolean | Enable/disable left panel | `False` |
| `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
@@ -735,10 +816,40 @@ codebase.
### 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}_pl", cls="mf-panel-left [mf-hidden]")
│ ├── Div (hide icon)
│ ├── Div (hide icon - absolute positioned)
│ ├── Div(id="{id}_cl")
│ │ └── [Left content]
│ └── 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}_pr", cls="mf-panel-right [mf-hidden]")
│ ├── Div (resizer-right)
│ ├── Div (hide icon)
│ ├── Div (hide icon - absolute positioned)
│ └── Div(id="{id}_cr")
│ └── [Right content]
└── Script # initResizer('{id}')
@@ -757,11 +868,12 @@ Div(id="{id}", cls="mf-panel")
**Note:**
- Left panel: hide icon, then content, then resizer (resizer on right edge)
- Right panel: resizer, then hide icon, then content (resizer on left edge)
- Hide icons are positioned at panel root level (not inside content div)
- Main content has an outer wrapper and inner content div with ID
- With title: uses grid layout (`mf-panel-body`) with sticky header and scrollable content
- Without title: hide icon is absolutely positioned at top-right with padding-top on panel
- Left panel: body/content then resizer (resizer on right edge)
- Right panel: resizer then body/content (resizer on left edge)
- `[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

View File

@@ -459,7 +459,7 @@
.mf-search-results {
margin-top: 0.5rem;
max-height: 200px;
/*max-height: 400px;*/
overflow: auto;
}
@@ -769,6 +769,38 @@
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 ************ */
/* *********************************************** */

View File

@@ -14,6 +14,7 @@ from myfasthtml.controls.CycleStateControl import CycleStateControl
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
from myfasthtml.controls.DataGridQuery import DataGridQuery, DG_QUERY_FILTER
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
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.icons.carbon import row, column, grid
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
# OPTIMIZATION: Pre-compiled regex to detect HTML special characters
@@ -129,6 +131,13 @@ class Commands(BaseCommands):
self._owner.on_click
).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):
def __init__(self, parent, settings=None, save_state=None, _id=None):
@@ -138,6 +147,11 @@ class DataGrid(MultipleInstance):
self.commands = Commands(self)
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
self._datagrid_filter = DataGridQuery(self)
self._datagrid_filter.bind_command("QueryChanged", self.commands.filter())
@@ -359,6 +373,10 @@ class DataGrid(MultipleInstance):
self._state.save()
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):
resize_cmd = self.commands.set_column_width()
move_cmd = self.commands.move_column()
@@ -646,10 +664,10 @@ class DataGrid(MultipleInstance):
Div(self._datagrid_filter,
Div(
self._selection_mode_selector,
self._columns_manager,
mk.icon(settings16_regular, command=self.commands.toggle_columns_manager(), tooltip="Show sidebar"),
cls="flex"),
cls="flex items-center justify-between mb-2"),
self.mk_table(),
self._panel.set_main(self.mk_table()),
Script(f"initDataGrid('{self._id}');"),
Mouse(self, combinations=self._mouse_support),
id=self._id,

View File

@@ -1,15 +1,33 @@
from fasthtml.components import Div
from fasthtml.components import *
from myfasthtml.controls.Dropdown import Dropdown
from myfasthtml.controls.helpers import mk
from myfasthtml.icons.fluent_p1 import settings16_regular
from myfasthtml.controls.Search import Search
from myfasthtml.controls.datagrid_objects import DataGridColumnState
from myfasthtml.core.instances import MultipleInstance
class DataGridColumnsManager(Dropdown):
class DataGridColumnsManager(MultipleInstance):
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id, align="right")
self.button = mk.icon(settings16_regular)
self.content = Div("DataGridColumnsManager")
super().__init__(parent, _id=_id)
@property
def columns(self):
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()

View File

@@ -8,7 +8,6 @@ from fasthtml.components import Div
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.controls.FileUpload import FileUpload
from myfasthtml.controls.Panel import Panel
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import mk
@@ -106,7 +105,7 @@ class DataGridsManager(MultipleInstance):
parent_id = self._tree.ensure_path(document.namespace)
tree_node = TreeNode(label=document.name, type="excel", parent=parent_id)
self._tree.add_node(tree_node, parent_id=parent_id)
return self._mk_tree(), self._tabs_manager.change_tab_content(tab_id, document.name, Panel(self).set_main(dg))
return self._mk_tree(), self._tabs_manager.change_tab_content(tab_id, document.name, dg)
def select_document(self, node_id):
document_id = self._tree.get_bag(node_id)
@@ -137,9 +136,7 @@ class DataGridsManager(MultipleInstance):
# Recreate the DataGrid with its saved state
dg = DataGrid(self._tabs_manager, _id=document.datagrid_id) # reload the state & settings
# Wrap in Panel
return Panel(self).set_main(dg)
return dg
def clear_tree(self):
self._state.elements = []

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass
from typing import Literal
from typing import Literal, Optional
from fasthtml.components import Div
from fasthtml.xtend import Script
@@ -42,6 +42,12 @@ class PanelIds:
class PanelConf:
left: bool = False
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):
@@ -55,12 +61,19 @@ class PanelState(DbObject):
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",
f"Toggle {side} side panel",
self._owner,
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"]):
"""
@@ -89,7 +102,7 @@ class Panel(MultipleInstance):
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)
self.conf = conf or PanelConf()
self.commands = Commands(self)
@@ -110,7 +123,7 @@ class Panel(MultipleInstance):
return self._mk_panel(side)
def toggle_side(self, side, visible):
def set_side_visible(self, side, visible):
if side == "left":
self._state.left_visible = visible
else:
@@ -118,6 +131,10 @@ class Panel(MultipleInstance):
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):
self._main = main
return self
@@ -137,6 +154,8 @@ class Panel(MultipleInstance):
visible = self._state.left_visible if side == "left" else self._state.right_visible
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(
cls=f"mf-resizer mf-resizer-{side}",
@@ -147,32 +166,60 @@ class Panel(MultipleInstance):
hide_icon = mk.icon(
subtract20_regular,
size=20,
command=self.commands.toggle_side(side, False),
command=self.commands.set_side_visible(side, False),
cls="mf-panel-hide-icon"
)
panel_cls = f"mf-panel-{side}"
if not visible:
panel_cls += " mf-hidden"
if show_title:
panel_cls += " mf-panel-with-title"
# Left panel: content then resizer (resizer on the right)
# Right panel: resizer then content (resizer on the left)
if side == "left":
return Div(
if show_title:
header = Div(
Div(title),
hide_icon,
Div(content, id=self._ids.content(side)),
resizer,
cls=panel_cls,
id=self._ids.panel(side)
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:
return Div(
resizer,
hide_icon,
Div(content, id=self._ids.content(side)),
cls=panel_cls,
id=self._ids.panel(side)
)
if side == "left":
return Div(
hide_icon,
Div(content, id=self._ids.content(side)),
resizer,
cls=panel_cls,
id=self._ids.panel(side)
)
else:
return Div(
resizer,
hide_icon,
Div(content, id=self._ids.content(side)),
cls=panel_cls,
id=self._ids.panel(side)
)
def _mk_main(self):
return Div(
@@ -196,12 +243,16 @@ class Panel(MultipleInstance):
if not enabled:
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
icon_cls = "hidden" if is_visible else f"mf-panel-show-icon mf-panel-show-icon-{side}"
return mk.icon(
more_horizontal20_regular,
command=self.commands.toggle_side(side, True),
command=self.commands.set_side_visible(side, True),
cls=icon_cls,
id=f"{self._id}_show_{side}"
)

View File

@@ -45,7 +45,8 @@ class Search(MultipleInstance):
items_names=None, # what is the name of the items to filter
items=None, # first set of items to filter
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
template: Callable[[Any], Any] = None): # once filtered, what to render ?
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.
@@ -64,8 +65,9 @@ class Search(MultipleInstance):
self.items = items or []
self.filtered = self.items.copy()
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.max_height = max_height
def set_items(self, items):
self.items = items
@@ -106,6 +108,7 @@ class Search(MultipleInstance):
*self._mk_search_results(),
id=f"{self._id}-results",
cls="mf-search-results",
style="max-height: 400px;" if self.max_height else None
),
id=f"{self._id}",
)

View File

@@ -117,7 +117,7 @@ class BaseInstance:
return _id
if _id.startswith("-") and parent is not None:
return f"{parent.get_prefix()}{_id}"
return f"{parent.get_id()}{_id}"
return _id

View File

@@ -96,7 +96,7 @@ class TestPanelBehaviour:
"""Test that toggle_side('left', False) sets _state.left_visible to False."""
panel = Panel(root_instance)
panel.toggle_side("left", False)
panel.set_side_visible("left", False)
assert panel._state.left_visible is False
@@ -105,7 +105,7 @@ class TestPanelBehaviour:
panel = Panel(root_instance)
panel._state.left_visible = False
panel.toggle_side("left", True)
panel.set_side_visible("left", 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."""
panel = Panel(root_instance)
panel.toggle_side("right", False)
panel.set_side_visible("right", False)
assert panel._state.right_visible is False
@@ -122,15 +122,45 @@ class TestPanelBehaviour:
panel = Panel(root_instance)
panel._state.right_visible = False
panel.toggle_side("right", True)
panel.set_side_visible("right", 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):
"""Test that toggle_side() returns a tuple (panel_element, show_icon_element)."""
panel = Panel(root_instance)
result = panel.toggle_side("left", False)
result = panel.toggle_side("left")
assert isinstance(result, tuple)
assert len(result) == 2
@@ -190,13 +220,39 @@ class TestPanelBehaviour:
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:
"""Tests for Panel HTML rendering."""
@pytest.fixture
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_left(Div("Left content"))
panel.set_right(Div("Right content"))
@@ -225,22 +281,58 @@ class TestPanelRender:
# 2. Left panel
def test_left_panel_renders_with_correct_structure(self, panel):
"""Test that left panel has content div before resizer.
def test_left_panel_renders_with_title_structure(self, panel):
"""Test that left panel with title has header + scrollable content.
Why these elements matter:
- Order (content then resizer): Critical for positioning resizer on the right side
- id: Required for HTMX targeting during toggle/resize operations
- cls Contains "mf-panel-left": CSS class for left panel styling
- mf-panel-body: Grid container for header + content layout
- mf-panel-header: Contains title and hide icon
- 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")))
# Step 1: Validate left panel global structure
expected = Div(
TestIcon("subtract20_regular"),
Div(id=panel.get_ids().left), # content div, tested in detail later
Div(cls=Contains("mf-panel-body")), # body with header + content
Div(cls=Contains("mf-resizer-left")), # resizer
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")
)
@@ -274,22 +366,58 @@ class TestPanelRender:
# 3. Right panel
def test_right_panel_renders_with_correct_structure(self, panel):
"""Test that right panel has resizer before content div.
def test_right_panel_renders_with_title_structure(self, panel):
"""Test that right panel with title has header + scrollable content.
Why these elements matter:
- Order (resizer then hide icon then content): Critical for positioning resizer on the left side
- id: Required for HTMX targeting during toggle/resize operations
- cls Contains "mf-panel-right": CSS class for right panel styling
- mf-panel-body: Grid container for header + content layout
- mf-panel-header: Contains title and hide icon
- 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")))
# Step 1: Validate right panel global structure
expected = Div(
Div(cls=Contains("mf-resizer-right")), # resizer
TestIcon("subtract20_regular"), # hide icon
Div(id=panel.get_ids().right), # content div, tested in detail later
Div(cls=Contains("mf-panel-body")), # body with header + content
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")
)
@@ -367,40 +495,57 @@ class TestPanelRender:
# 5. Icons
def test_hide_icon_in_left_panel_has_correct_command(self, panel):
"""Test that hide icon in left panel triggers toggle_side command.
def test_hide_icon_in_left_panel_header(self, panel):
"""Test that hide icon in left panel header has correct structure.
Why these elements matter:
- 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")))
header = find_one(left_panel, Div(cls=Contains("mf-panel-header")))
# Find the hide icon (should be wrapped by mk.icon)
hide_icons = find(left_panel, Div(cls=Contains("mf-panel-hide-icon")))
assert len(hide_icons) == 1, "Left panel should contain exactly one hide icon"
hide_icons = find(header, Div(cls=Contains("mf-panel-hide-icon")))
assert len(hide_icons) == 1, "Header should contain exactly one hide icon"
# Verify it contains the subtract icon
expected = Div(
TestIconNotStr("subtract20_regular"),
cls=Contains("mf-panel-hide-icon")
)
assert matches(hide_icons[0], expected)
def test_hide_icon_in_right_panel_has_correct_command(self, panel):
"""Test that hide icon in right panel triggers toggle_side command.
def test_hide_icon_in_right_panel_header(self, panel):
"""Test that hide icon in right panel header has correct structure.
Why these elements matter:
- 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")))
header = find_one(right_panel, Div(cls=Contains("mf-panel-header")))
# Find the hide icon (should be wrapped by mk.icon)
hide_icons = find(right_panel, Div(cls=Contains("mf-panel-hide-icon")))
assert len(hide_icons) == 1, "Right panel should contain exactly one hide icon"
hide_icons = find(header, Div(cls=Contains("mf-panel-hide-icon")))
assert len(hide_icons) == 1, "Header 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(
TestIconNotStr("subtract20_regular"),
cls=Contains("mf-panel-hide-icon")
@@ -459,6 +604,26 @@ class TestPanelRender:
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
def test_main_panel_contains_show_icons_and_content(self, panel):