I can show and hide the columns comanger
This commit is contained in:
@@ -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:
|
||||
|
||||
182
docs/Panel.md
182
docs/Panel.md
@@ -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
|
||||
|
||||
|
||||
@@ -459,7 +459,7 @@
|
||||
|
||||
.mf-search-results {
|
||||
margin-top: 0.5rem;
|
||||
max-height: 400px;
|
||||
/*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 ************ */
|
||||
/* *********************************************** */
|
||||
|
||||
@@ -14,7 +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
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
||||
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
||||
from myfasthtml.controls.helpers import mk
|
||||
@@ -26,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
|
||||
@@ -130,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):
|
||||
@@ -139,7 +147,10 @@ class DataGrid(MultipleInstance):
|
||||
self.commands = Commands(self)
|
||||
self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState
|
||||
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
# 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)
|
||||
@@ -362,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()
|
||||
@@ -649,7 +664,7 @@ 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._panel.set_main(self.mk_table()),
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.Dropdown import Dropdown
|
||||
from myfasthtml.controls.Search import Search
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
||||
from myfasthtml.controls.helpers import mk
|
||||
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):
|
||||
super().__init__(parent, _id=_id, align="right")
|
||||
self.button = mk.icon(settings16_regular)
|
||||
self.content = self.create_content()
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
@property
|
||||
def columns(self):
|
||||
return self._parent._state.columns
|
||||
|
||||
def mk_column(self, col_def: DataGridColumnState):
|
||||
return Div(
|
||||
@@ -20,14 +20,14 @@ class DataGridColumnsManager(Dropdown):
|
||||
cls="flex mb-1",
|
||||
)
|
||||
|
||||
def create_content(self):
|
||||
def render(self):
|
||||
return Search(self,
|
||||
items_names="Columns",
|
||||
items=self.columns,
|
||||
get_attr=lambda x: x.col_id,
|
||||
template=self.mk_column
|
||||
template=self.mk_column,
|
||||
max_height=None
|
||||
)
|
||||
|
||||
@property
|
||||
def columns(self):
|
||||
return self._parent._state.columns
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -66,6 +67,7 @@ class Search(MultipleInstance):
|
||||
self.get_attr = get_attr or (lambda x: x)
|
||||
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}",
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user