Compare commits
2 Commits
3abfab8e97
...
05d4e5cd89
| Author | SHA1 | Date | |
|---|---|---|---|
| 05d4e5cd89 | |||
| e31d9026ce |
@@ -258,7 +258,7 @@ def test_i_can_render_component_with_no_data(self, component):
|
||||
|
||||
**Test order:**
|
||||
1. **First test:** Global structure (UTR-11.1)
|
||||
2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.10)
|
||||
2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.11)
|
||||
|
||||
---
|
||||
|
||||
@@ -410,8 +410,19 @@ expected = Div(style="width: 250px; overflow: hidden; display: flex;")
|
||||
|
||||
**How to choose:**
|
||||
1. **Read the source code** to see how the icon is rendered
|
||||
2. If `mk.icon()` or equivalent wraps the icon in a Div → use `TestIcon()`
|
||||
3. If the icon is directly included without wrapper → use `TestIconNotStr()`
|
||||
2. If `mk.icon()` wraps the icon in a Div → use `TestIcon()` (default `wrapper="div"`)
|
||||
3. If `mk.label(..., icon=...)` wraps the icon in a Span → use `TestIcon(..., wrapper="span")`
|
||||
4. If the icon is directly included without wrapper → use `TestIconNotStr()`
|
||||
|
||||
**The `wrapper` parameter:**
|
||||
|
||||
Different `mk` helpers use different wrappers for icons:
|
||||
|
||||
| Helper method | Wrapper element | TestIcon usage |
|
||||
|---------------|-----------------|----------------|
|
||||
| `mk.icon(my_icon)` | `<div>` | `TestIcon("name")` |
|
||||
| `mk.label("Text", icon=my_icon)` | `<span>` | `TestIcon("name", wrapper="span")` |
|
||||
| Direct: `Div(my_icon)` | none | `TestIconNotStr("name")` |
|
||||
|
||||
**The `name` parameter:**
|
||||
- **Exact name**: Use the exact import name (e.g., `TestIcon("panel_right_expand20_regular")`) to validate a specific icon
|
||||
@@ -420,25 +431,30 @@ expected = Div(style="width: 250px; overflow: hidden; display: flex;")
|
||||
**Examples:**
|
||||
|
||||
```python
|
||||
# Example 1: Wrapped icon (typically with mk.icon())
|
||||
# Example 1: Icon via mk.icon() - wrapper is Div (default)
|
||||
# Source code: mk.icon(panel_right_expand20_regular, size=20)
|
||||
# Rendered: <div><NotStr .../></div>
|
||||
# Rendered: <div><svg .../></div>
|
||||
expected = Header(
|
||||
Div(
|
||||
TestIcon("panel_right_expand20_regular"), # ✅ With wrapper
|
||||
TestIcon("panel_right_expand20_regular"), # ✅ wrapper="div" (default)
|
||||
cls=Contains("flex", "gap-1")
|
||||
)
|
||||
)
|
||||
|
||||
# Example 2: Direct icon (used without helper)
|
||||
# Example 2: Icon via mk.label() - wrapper is Span
|
||||
# Source code: mk.label("Back", icon=chevron_left20_regular, command=...)
|
||||
# Rendered: <label><span><svg .../></span><span>Back</span></label>
|
||||
back_icon = find_one(details, TestIcon("chevron_left20_regular", wrapper="span")) # ✅ wrapper="span"
|
||||
|
||||
# Example 3: Direct icon (used without helper)
|
||||
# Source code: Span(dismiss_circle16_regular, cls="icon")
|
||||
# Rendered: <span><NotStr .../></span>
|
||||
# Rendered: <span><svg .../></span>
|
||||
expected = Span(
|
||||
TestIconNotStr("dismiss_circle16_regular"), # ✅ Without wrapper
|
||||
cls=Contains("icon")
|
||||
)
|
||||
|
||||
# Example 3: Verify any wrapped icon
|
||||
# Example 4: Verify any wrapped icon
|
||||
expected = Div(
|
||||
TestIcon(""), # Accepts any wrapped icon
|
||||
cls=Contains("icon-wrapper")
|
||||
@@ -446,7 +462,10 @@ expected = Div(
|
||||
```
|
||||
|
||||
**Debugging tip:**
|
||||
If your test fails with `TestIcon()`, try `TestIconNotStr()` and vice-versa. The error message will show you the actual structure.
|
||||
If your test fails with `TestIcon()`:
|
||||
1. Check if the wrapper is `<span>` instead of `<div>` → try `wrapper="span"`
|
||||
2. Check if there's no wrapper at all → try `TestIconNotStr()`
|
||||
3. The error message will show you the actual structure
|
||||
|
||||
---
|
||||
|
||||
@@ -467,11 +486,60 @@ expected = Script("(function() { const id = '...'; initResizer(id); })()")
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.9: Remove default `enctype` attribute when searching for Form elements**
|
||||
|
||||
**Principle:** FastHTML's `Form()` component automatically adds `enctype="multipart/form-data"` as a default attribute. When using `find()` or `find_one()` to search for a Form, you must remove this attribute from the expected pattern.
|
||||
|
||||
**Why:** The actual Form in your component may not have this attribute, causing the match to fail.
|
||||
|
||||
**Problem:**
|
||||
```python
|
||||
# ❌ FAILS - Form() has default enctype that may not exist in actual form
|
||||
form = find_one(details, Form()) # AssertionError: Found 0 elements
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```python
|
||||
# ✅ WORKS - Remove the default enctype attribute
|
||||
expected_form = Form()
|
||||
del expected_form.attrs["enctype"]
|
||||
form = find_one(details, expected_form)
|
||||
```
|
||||
|
||||
**Alternative - Search with specific attribute:**
|
||||
```python
|
||||
# ✅ ALSO WORKS - Search by a known attribute
|
||||
form = find_one(details, Form(cls=Contains("my-form-class")))
|
||||
# But still need to delete enctype if Form() is used as pattern
|
||||
```
|
||||
|
||||
**Complete example:**
|
||||
```python
|
||||
def test_column_details_contains_form(self, component):
|
||||
"""Test that column details contains a form with required fields."""
|
||||
details = component.mk_column_details(col_def)
|
||||
|
||||
# Create Form pattern and remove default enctype
|
||||
expected_form = Form()
|
||||
del expected_form.attrs["enctype"]
|
||||
|
||||
form = find_one(details, expected_form)
|
||||
assert form is not None
|
||||
|
||||
# Now search within the found form
|
||||
title_input = find_one(form, Input(name="title"))
|
||||
assert title_input is not None
|
||||
```
|
||||
|
||||
**Note:** This is a FastHTML-specific behavior. Always check for similar default attributes when tests fail unexpectedly with "Found 0 elements".
|
||||
|
||||
---
|
||||
|
||||
### **HOW TO DOCUMENT TESTS**
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.9: Justify the choice of tested elements**
|
||||
#### **UTR-11.10: Justify the choice of tested elements**
|
||||
|
||||
**Principle:** In the test documentation section (after the description docstring), explain **why each tested element or attribute was chosen**. What makes it important for the functionality?
|
||||
|
||||
@@ -518,7 +586,7 @@ def test_left_drawer_is_rendered_when_open(self, layout):
|
||||
|
||||
---
|
||||
|
||||
#### **UTR-11.10: Count tests with explicit messages**
|
||||
#### **UTR-11.11: Count tests with explicit messages**
|
||||
|
||||
**Principle:** When you count elements with `assert len()`, ALWAYS add an explicit message explaining why this number is expected.
|
||||
|
||||
@@ -548,7 +616,7 @@ assert len(resizers) == 1
|
||||
2. **Documentation format**: Every render test MUST have a docstring with:
|
||||
- First line: Brief description of what is being tested
|
||||
- Blank line
|
||||
- Justification section explaining why tested elements matter (see UTR-11.9)
|
||||
- Justification section explaining why tested elements matter (see UTR-11.10)
|
||||
- List of important elements/attributes being tested with explanations (in English)
|
||||
|
||||
3. **No inline comments**: Do NOT add comments on each line of the expected structure (except for structural clarification in global layout tests like `# left drawer`)
|
||||
@@ -572,7 +640,7 @@ assert len(resizers) == 1
|
||||
|
||||
---
|
||||
|
||||
#### **Summary: The 11 UTR-11 sub-rules**
|
||||
#### **Summary: The 12 UTR-11 sub-rules**
|
||||
|
||||
**Prerequisite**
|
||||
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
|
||||
@@ -590,10 +658,11 @@ assert len(resizers) == 1
|
||||
- **UTR-11.6**: Always `Contains()` for `cls` and `style`
|
||||
- **UTR-11.7**: `TestIcon()` or `TestIconNotStr()` to test icon presence
|
||||
- **UTR-11.8**: `TestScript()` for JavaScript
|
||||
- **UTR-11.9**: Remove default `enctype` from `Form()` patterns
|
||||
|
||||
**How to document**
|
||||
- **UTR-11.9**: Justify the choice of tested elements
|
||||
- **UTR-11.10**: Explicit messages for `assert len()`
|
||||
- **UTR-11.10**: Justify the choice of tested elements
|
||||
- **UTR-11.11**: Explicit messages for `assert len()`
|
||||
|
||||
---
|
||||
|
||||
@@ -601,7 +670,7 @@ assert len(resizers) == 1
|
||||
- Reference specific patterns from the documentation
|
||||
- Explain why you chose to test certain elements and not others
|
||||
- Justify the use of predicates vs exact values
|
||||
- Always include justification documentation (see UTR-11.9)
|
||||
- Always include justification documentation (see UTR-11.10)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -59,6 +59,16 @@
|
||||
* Compatible with DaisyUI 5
|
||||
*/
|
||||
|
||||
.mf-button {
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.mf-button:hover {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
|
||||
|
||||
.mf-tooltip-container {
|
||||
background: var(--color-base-200);
|
||||
padding: 5px 10px;
|
||||
@@ -1161,3 +1171,19 @@
|
||||
.dt2-moving {
|
||||
transition: transform 300ms ease;
|
||||
}
|
||||
|
||||
/* *********************************************** */
|
||||
/* ******** DataGrid Column Manager ********** */
|
||||
/* *********************************************** */
|
||||
.dt2-column-manager-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dt2-column-manager-label:hover {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
|
||||
@@ -1200,8 +1200,10 @@ function triggerHtmxAction(elementId, config, combinationStr, isInside, event) {
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent default if we found any match and not in input context
|
||||
if (currentMatches.length > 0 && !isInInputContext()) {
|
||||
// Prevent default only if click was INSIDE a registered element
|
||||
// Clicks outside should preserve native behavior (checkboxes, buttons, etc.)
|
||||
const anyMatchInside = currentMatches.some(match => match.isInside);
|
||||
if (currentMatches.length > 0 && anyMatchInside && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ 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
|
||||
from myfasthtml.controls.helpers import mk, icons
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
|
||||
from myfasthtml.core.dbmanager import DbObject
|
||||
@@ -138,6 +138,13 @@ class Commands(BaseCommands):
|
||||
self._owner.toggle_columns_manager
|
||||
).htmx(target=None)
|
||||
|
||||
def on_column_changed(self):
|
||||
return Command("OnColumnChanged",
|
||||
"Column definition changed",
|
||||
self._owner,
|
||||
self._owner.on_column_changed
|
||||
)
|
||||
|
||||
|
||||
class DataGrid(MultipleInstance):
|
||||
def __init__(self, parent, settings=None, save_state=None, _id=None):
|
||||
@@ -170,6 +177,8 @@ class DataGrid(MultipleInstance):
|
||||
|
||||
# add columns manager
|
||||
self._columns_manager = DataGridColumnsManager(self)
|
||||
self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed())
|
||||
self._columns_manager.bind_command("UpdateColumn", self.commands.on_column_changed())
|
||||
|
||||
# other definitions
|
||||
self._mouse_support = {
|
||||
@@ -354,7 +363,7 @@ class DataGrid(MultipleInstance):
|
||||
def filter(self):
|
||||
logger.debug("filter")
|
||||
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
|
||||
return self.render_partial("body", redraw_scrollbars=True)
|
||||
return self.render_partial("body")
|
||||
|
||||
def on_click(self, combination, is_inside, cell_id):
|
||||
logger.debug(f"on_click {combination=} {is_inside=} {cell_id=}")
|
||||
@@ -365,6 +374,10 @@ class DataGrid(MultipleInstance):
|
||||
|
||||
return self.render_partial()
|
||||
|
||||
def on_column_changed(self):
|
||||
logger.debug("on_column_changed")
|
||||
return self.render_partial("table")
|
||||
|
||||
def change_selection_mode(self):
|
||||
logger.debug(f"change_selection_mode")
|
||||
new_state = self._selection_mode_selector.get_state()
|
||||
@@ -377,17 +390,27 @@ class DataGrid(MultipleInstance):
|
||||
logger.debug(f"toggle_columns_manager")
|
||||
self._panel.set_right(self._columns_manager)
|
||||
|
||||
def save_state(self):
|
||||
self._state.save()
|
||||
|
||||
def get_state(self):
|
||||
return self._state
|
||||
|
||||
def mk_headers(self):
|
||||
resize_cmd = self.commands.set_column_width()
|
||||
move_cmd = self.commands.move_column()
|
||||
|
||||
def _mk_header_name(col_def: DataGridColumnState):
|
||||
return Div(
|
||||
mk.label(col_def.title, name="dt2-header-title"),
|
||||
mk.label(col_def.title, icon=icons.get(col_def.type, None)),
|
||||
# make room for sort and filter indicators
|
||||
cls="flex truncate cursor-default",
|
||||
)
|
||||
|
||||
def _mk_header(col_def: DataGridColumnState):
|
||||
if not col_def.visible:
|
||||
return None
|
||||
|
||||
return Div(
|
||||
_mk_header_name(col_def),
|
||||
Div(cls="dt2-resize-handle", data_command_id=resize_cmd.id),
|
||||
@@ -397,7 +420,7 @@ class DataGrid(MultipleInstance):
|
||||
cls="dt2-cell dt2-resizable flex",
|
||||
)
|
||||
|
||||
header_class = "dt2-row dt2-header" + "" if self._settings.header_visible else " hidden"
|
||||
header_class = "dt2-row dt2-header"
|
||||
return Div(
|
||||
*[_mk_header(col_def) for col_def in self._state.columns],
|
||||
cls=header_class,
|
||||
@@ -470,7 +493,7 @@ class DataGrid(MultipleInstance):
|
||||
return None
|
||||
|
||||
if not col_def.visible:
|
||||
return OptimizedDiv(cls="dt2-col-hidden")
|
||||
return None
|
||||
|
||||
value = self._state.ns_fast_access[col_def.col_id][row_index]
|
||||
content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower)
|
||||
@@ -509,7 +532,7 @@ class DataGrid(MultipleInstance):
|
||||
|
||||
return rows
|
||||
|
||||
def mk_body_container(self):
|
||||
def mk_body_wrapper(self):
|
||||
return Div(
|
||||
self.mk_body(),
|
||||
cls="dt2-body-container",
|
||||
@@ -535,28 +558,12 @@ class DataGrid(MultipleInstance):
|
||||
id=f"tf_{self._id}"
|
||||
)
|
||||
|
||||
def mk_table(self):
|
||||
def mk_table_wrapper(self):
|
||||
return Div(
|
||||
self.mk_selection_manager(),
|
||||
|
||||
# Grid table with header, body, footer
|
||||
Div(
|
||||
# Header container - no scroll
|
||||
Div(
|
||||
self.mk_headers(),
|
||||
cls="dt2-header-container"
|
||||
),
|
||||
self.mk_table(),
|
||||
|
||||
self.mk_body_container(), # Body container - scroll via JS, scrollbars hidden
|
||||
|
||||
# Footer container - no scroll
|
||||
Div(
|
||||
self.mk_footers(),
|
||||
cls="dt2-footer-container"
|
||||
),
|
||||
cls="dt2-table",
|
||||
id=f"t_{self._id}"
|
||||
),
|
||||
# Custom scrollbars overlaid
|
||||
Div(
|
||||
# Vertical scrollbar wrapper (right side)
|
||||
@@ -575,6 +582,26 @@ class DataGrid(MultipleInstance):
|
||||
id=f"tw_{self._id}"
|
||||
)
|
||||
|
||||
def mk_table(self):
|
||||
# Grid table with header, body, footer
|
||||
return Div(
|
||||
# Header container - no scroll
|
||||
Div(
|
||||
self.mk_headers(),
|
||||
cls="dt2-header-container"
|
||||
),
|
||||
|
||||
self.mk_body_wrapper(), # Body container - scroll via JS, scrollbars hidden
|
||||
|
||||
# Footer container - no scroll
|
||||
Div(
|
||||
self.mk_footers(),
|
||||
cls="dt2-footer-container"
|
||||
),
|
||||
cls="dt2-table",
|
||||
id=f"t_{self._id}"
|
||||
)
|
||||
|
||||
def mk_selection_manager(self):
|
||||
|
||||
extra_attr = {
|
||||
@@ -667,7 +694,7 @@ class DataGrid(MultipleInstance):
|
||||
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()),
|
||||
self._panel.set_main(self.mk_table_wrapper()),
|
||||
Script(f"initDataGrid('{self._id}');"),
|
||||
Mouse(self, combinations=self._mouse_support),
|
||||
id=self._id,
|
||||
@@ -675,7 +702,7 @@ class DataGrid(MultipleInstance):
|
||||
style="height: 100%; grid-template-rows: auto 1fr;"
|
||||
)
|
||||
|
||||
def render_partial(self, fragment="cell", redraw_scrollbars=False):
|
||||
def render_partial(self, fragment="cell"):
|
||||
"""
|
||||
|
||||
:param fragment: cell | body
|
||||
@@ -689,10 +716,15 @@ class DataGrid(MultipleInstance):
|
||||
}
|
||||
|
||||
if fragment == "body":
|
||||
body_container = self.mk_body_container()
|
||||
body_container = self.mk_body_wrapper()
|
||||
body_container.attrs.update(extra_attr)
|
||||
res.append(body_container)
|
||||
|
||||
elif fragment == "table":
|
||||
table = self.mk_table()
|
||||
table.attrs.update(extra_attr)
|
||||
res.append(table)
|
||||
|
||||
res.append(self.mk_selection_manager())
|
||||
|
||||
return tuple(res)
|
||||
|
||||
@@ -1,33 +1,190 @@
|
||||
import logging
|
||||
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
from myfasthtml.controls.Search import Search
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
||||
from myfasthtml.controls.helpers import icons, mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import ColumnType
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
from myfasthtml.icons.fluent_p1 import chevron_right20_regular, chevron_left20_regular
|
||||
|
||||
logger = logging.getLogger("DataGridColumnsManager")
|
||||
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def toggle_column(self, col_id):
|
||||
return Command(f"ToggleColumn",
|
||||
f"Toggle column {col_id}",
|
||||
self._owner,
|
||||
self._owner.toggle_column,
|
||||
kwargs={"col_id": col_id}).htmx(swap="outerHTML", target=f"#tcolman_{self._id}-{col_id}")
|
||||
|
||||
def show_column_details(self, col_id):
|
||||
return Command(f"ShowColumnDetails",
|
||||
f"Show column details {col_id}",
|
||||
self._owner,
|
||||
self._owner.show_column_details,
|
||||
kwargs={"col_id": col_id}).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||
|
||||
def show_all_columns(self):
|
||||
return Command(f"ShowAllColumns",
|
||||
f"Show all columns",
|
||||
self._owner,
|
||||
self._owner.show_all_columns).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||
|
||||
def update_column(self, col_id):
|
||||
return Command(f"UpdateColumn",
|
||||
f"Update column {col_id}",
|
||||
self._owner,
|
||||
self._owner.update_column,
|
||||
kwargs={"col_id": col_id}
|
||||
).htmx(target=f"#{self._id}", swap="innerHTML")
|
||||
|
||||
|
||||
class DataGridColumnsManager(MultipleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.commands = Commands(self)
|
||||
|
||||
@property
|
||||
def columns(self):
|
||||
return self._parent._state.columns
|
||||
return self._parent.get_state().columns
|
||||
|
||||
def mk_column(self, col_def: DataGridColumnState):
|
||||
def _get_col_def_from_col_id(self, col_id):
|
||||
cols_defs = [c for c in self.columns if c.col_id == col_id]
|
||||
if not cols_defs:
|
||||
return None
|
||||
else:
|
||||
return cols_defs[0]
|
||||
|
||||
def toggle_column(self, col_id):
|
||||
logger.debug(f"toggle_column {col_id=}")
|
||||
col_def = self._get_col_def_from_col_id(col_id)
|
||||
if col_def is None:
|
||||
logger.debug(f" column '{col_id}' is not found.")
|
||||
return Div(f"Column '{col_id}' not found")
|
||||
|
||||
col_def.visible = not col_def.visible
|
||||
self._parent.save_state()
|
||||
return self.mk_column_label(col_def)
|
||||
|
||||
def show_column_details(self, col_id):
|
||||
logger.debug(f"show_column_details {col_id=}")
|
||||
col_def = self._get_col_def_from_col_id(col_id)
|
||||
if col_def is None:
|
||||
logger.debug(f" column '{col_id}' is not found.")
|
||||
return Div(f"Column '{col_id}' not found")
|
||||
|
||||
return self.mk_column_details(col_def)
|
||||
|
||||
def show_all_columns(self):
|
||||
return self.mk_all_columns()
|
||||
|
||||
def update_column(self, col_id, client_response):
|
||||
logger.debug(f"update_column {col_id=}, {client_response=}")
|
||||
col_def = self._get_col_def_from_col_id(col_id)
|
||||
if col_def is None:
|
||||
logger.debug(f" column '{col_id}' is not found.")
|
||||
else:
|
||||
for k, v in client_response.items():
|
||||
if not hasattr(col_def, k):
|
||||
continue
|
||||
|
||||
if k == "visible":
|
||||
col_def.visible = v == "on"
|
||||
elif k == "type":
|
||||
col_def.type = ColumnType(v)
|
||||
elif k == "width":
|
||||
col_def.width = int(v)
|
||||
else:
|
||||
setattr(col_def, k, v)
|
||||
|
||||
# save the new values
|
||||
self._parent.save_state()
|
||||
|
||||
return self.mk_all_columns()
|
||||
|
||||
def mk_column_label(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",
|
||||
mk.mk(
|
||||
Input(type="checkbox", cls="checkbox checkbox-sm", checked=col_def.visible),
|
||||
command=self.commands.toggle_column(col_def.col_id)
|
||||
),
|
||||
mk.mk(
|
||||
Div(
|
||||
Div(mk.label(col_def.col_id, icon=icons.get(col_def.type, None), cls="ml-2")),
|
||||
Div(mk.icon(chevron_right20_regular), cls="mr-2"),
|
||||
cls="dt2-column-manager-label"
|
||||
),
|
||||
command=self.commands.show_column_details(col_def.col_id)
|
||||
),
|
||||
cls="flex mb-1 items-center",
|
||||
id=f"tcolman_{self._id}-{col_def.col_id}"
|
||||
)
|
||||
|
||||
def render(self):
|
||||
def mk_column_details(self, col_def: DataGridColumnState):
|
||||
size = "sm"
|
||||
return Div(
|
||||
mk.label("Back", icon=chevron_left20_regular, command=self.commands.show_all_columns()),
|
||||
Form(
|
||||
Fieldset(
|
||||
Label("Column Id"),
|
||||
Input(name="col_id",
|
||||
cls=f"input input-{size}",
|
||||
value=col_def.col_id,
|
||||
readonly=True),
|
||||
|
||||
Label("Title"),
|
||||
Input(name="title",
|
||||
cls=f"input input-{size}",
|
||||
value=col_def.title),
|
||||
|
||||
Label("Visible"),
|
||||
Input(name="visible",
|
||||
type="checkbox",
|
||||
cls=f"checkbox checkbox-{size}",
|
||||
checked="true" if col_def.visible else None),
|
||||
|
||||
Label("type"),
|
||||
Select(
|
||||
*[Option(option.value, value=option.value, selected=option == col_def.type) for option in ColumnType],
|
||||
name="type",
|
||||
cls=f"select select-{size}",
|
||||
value=col_def.title,
|
||||
),
|
||||
|
||||
Label("Width"),
|
||||
Input(name="width",
|
||||
type="number",
|
||||
cls=f"input input-{size}",
|
||||
value=col_def.width),
|
||||
|
||||
legend="Column details",
|
||||
cls="fieldset border-base-300 rounded-box"
|
||||
),
|
||||
mk.dialog_buttons(on_ok=self.commands.update_column(col_def.col_id),
|
||||
on_cancel=self.commands.show_all_columns()),
|
||||
cls="mb-1",
|
||||
),
|
||||
)
|
||||
|
||||
def mk_all_columns(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_label,
|
||||
max_height=None
|
||||
)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self.mk_all_columns(),
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
|
||||
@@ -2,7 +2,14 @@ from fasthtml.components import *
|
||||
|
||||
from myfasthtml.core.bindings import Binding
|
||||
from myfasthtml.core.commands import Command, CommandTemplate
|
||||
from myfasthtml.core.constants import ColumnType
|
||||
from myfasthtml.core.utils import merge_classes
|
||||
from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_row20_regular, \
|
||||
number_symbol20_regular
|
||||
from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \
|
||||
checkbox_checked20_filled
|
||||
from myfasthtml.icons.fluent_p2 import text_bullet_list_square20_regular, text_field20_regular
|
||||
from myfasthtml.icons.fluent_p3 import calendar_ltr20_regular
|
||||
|
||||
|
||||
class Ids:
|
||||
@@ -96,7 +103,7 @@ class mk:
|
||||
command: Command | CommandTemplate = None,
|
||||
binding: Binding = None,
|
||||
**kwargs):
|
||||
merged_cls = merge_classes("flex truncate", cls, kwargs)
|
||||
merged_cls = merge_classes("flex truncate items-center", "mf-button" if command else None, cls, kwargs)
|
||||
icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None
|
||||
text_part = Span(text, cls=f"text-{size}")
|
||||
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
|
||||
@@ -138,3 +145,19 @@ class mk:
|
||||
ft = mk.manage_command(ft, command) if command else ft
|
||||
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
|
||||
return ft
|
||||
|
||||
|
||||
icons = {
|
||||
None: question20_regular,
|
||||
True: checkbox_checked20_regular,
|
||||
False: checkbox_unchecked20_regular,
|
||||
|
||||
"Brain": brain_circuit20_regular,
|
||||
|
||||
ColumnType.RowIndex: number_symbol20_regular,
|
||||
ColumnType.Text: text_field20_regular,
|
||||
ColumnType.Number: number_row20_regular,
|
||||
ColumnType.Datetime: calendar_ltr20_regular,
|
||||
ColumnType.Bool: checkbox_checked20_filled,
|
||||
ColumnType.List: text_bullet_list_square20_regular,
|
||||
}
|
||||
|
||||
@@ -186,8 +186,9 @@ class TestObject:
|
||||
|
||||
|
||||
class TestIcon(TestObject):
|
||||
def __init__(self, name: Optional[str] = '', command=None):
|
||||
super().__init__("div")
|
||||
def __init__(self, name: Optional[str] = '', wrapper="div", command=None):
|
||||
super().__init__(wrapper)
|
||||
self.wrapper = wrapper
|
||||
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
||||
self.children = [
|
||||
TestObject(NotStr, s=Regex(f'<svg name="\\w+-{self.name}'))
|
||||
@@ -196,7 +197,7 @@ class TestIcon(TestObject):
|
||||
self.attrs |= command.get_htmx_params()
|
||||
|
||||
def __str__(self):
|
||||
return f'<div><svg name="{self.name}" .../></div>'
|
||||
return f'<{self.wrapper}><svg name="{self.name}" .../></{self.wrapper}>'
|
||||
|
||||
|
||||
class TestIconNotStr(TestObject):
|
||||
|
||||
469
tests/controls/test_datagrid_columns_manager.py
Normal file
469
tests/controls/test_datagrid_columns_manager.py
Normal file
@@ -0,0 +1,469 @@
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from fasthtml.common import Div, Input, Label, Form, Fieldset, Select
|
||||
|
||||
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager, Commands
|
||||
from myfasthtml.controls.Search import Search
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
||||
from myfasthtml.core.constants import ColumnType
|
||||
from myfasthtml.core.instances import InstancesManager, MultipleInstance
|
||||
from myfasthtml.test.matcher import (
|
||||
matches, find_one, find, Contains, TestIcon, TestObject
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockDatagridState:
|
||||
"""Mock state object that mimics DatagridState."""
|
||||
columns: list = field(default_factory=list)
|
||||
|
||||
|
||||
class MockDataGrid(MultipleInstance):
|
||||
"""Mock DataGrid parent for testing DataGridColumnsManager."""
|
||||
|
||||
def __init__(self, parent, columns=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._state = MockDatagridState(columns=columns or [])
|
||||
self._save_state_called = False
|
||||
|
||||
def get_state(self):
|
||||
return self._state
|
||||
|
||||
def save_state(self):
|
||||
self._save_state_called = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_datagrid(root_instance):
|
||||
"""Create a mock DataGrid with sample columns."""
|
||||
columns = [
|
||||
DataGridColumnState(col_id="name", col_index=0, title="Name", type=ColumnType.Text, visible=True),
|
||||
DataGridColumnState(col_id="age", col_index=1, title="Age", type=ColumnType.Number, visible=True),
|
||||
DataGridColumnState(col_id="email", col_index=2, title="Email", type=ColumnType.Text, visible=False),
|
||||
]
|
||||
yield MockDataGrid(root_instance, columns=columns, _id="test-datagrid")
|
||||
InstancesManager.reset()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def columns_manager(mock_datagrid):
|
||||
"""Create a DataGridColumnsManager instance for testing."""
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
yield DataGridColumnsManager(mock_datagrid)
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
|
||||
class TestDataGridColumnsManagerBehaviour:
|
||||
"""Tests for DataGridColumnsManager behavior and logic."""
|
||||
|
||||
# =========================================================================
|
||||
# Initialization
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_create_columns_manager(self, mock_datagrid):
|
||||
"""Test that DataGridColumnsManager can be created with a DataGrid parent."""
|
||||
cm = DataGridColumnsManager(mock_datagrid)
|
||||
|
||||
assert cm is not None
|
||||
assert cm._parent == mock_datagrid
|
||||
assert isinstance(cm.commands, Commands)
|
||||
|
||||
# =========================================================================
|
||||
# Columns Property
|
||||
# =========================================================================
|
||||
|
||||
def test_columns_property_returns_parent_state_columns(self, columns_manager, mock_datagrid):
|
||||
"""Test that columns property returns columns from parent's state."""
|
||||
columns = columns_manager.columns
|
||||
|
||||
assert columns == mock_datagrid.get_state().columns
|
||||
assert len(columns) == 3
|
||||
assert columns[0].col_id == "name"
|
||||
|
||||
# =========================================================================
|
||||
# Get Column Definition
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_get_existing_column_by_id(self, columns_manager):
|
||||
"""Test finding an existing column by its ID."""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
|
||||
assert col_def is not None
|
||||
assert col_def.col_id == "name"
|
||||
assert col_def.title == "Name"
|
||||
|
||||
def test_i_cannot_get_nonexistent_column(self, columns_manager):
|
||||
"""Test that getting a nonexistent column returns None."""
|
||||
col_def = columns_manager._get_col_def_from_col_id("nonexistent")
|
||||
|
||||
assert col_def is None
|
||||
|
||||
# =========================================================================
|
||||
# Toggle Column Visibility
|
||||
# =========================================================================
|
||||
|
||||
@pytest.mark.parametrize("col_id, initial_visible, expected_visible", [
|
||||
("name", True, False), # visible -> hidden
|
||||
("email", False, True), # hidden -> visible
|
||||
])
|
||||
def test_i_can_toggle_column_visibility(self, columns_manager, col_id, initial_visible, expected_visible):
|
||||
"""Test toggling column visibility from visible to hidden and vice versa."""
|
||||
col_def = columns_manager._get_col_def_from_col_id(col_id)
|
||||
assert col_def.visible == initial_visible
|
||||
|
||||
columns_manager.toggle_column(col_id)
|
||||
|
||||
assert col_def.visible == expected_visible
|
||||
|
||||
def test_toggle_column_saves_state(self, columns_manager, mock_datagrid):
|
||||
"""Test that toggle_column calls save_state on parent."""
|
||||
mock_datagrid._save_state_called = False
|
||||
|
||||
columns_manager.toggle_column("name")
|
||||
|
||||
assert mock_datagrid._save_state_called is True
|
||||
|
||||
def test_toggle_column_returns_column_label(self, columns_manager):
|
||||
"""Test that toggle_column returns the updated column label."""
|
||||
result = columns_manager.toggle_column("name")
|
||||
|
||||
# Result should be a Div with the column label structure
|
||||
assert result is not None
|
||||
assert hasattr(result, 'tag')
|
||||
|
||||
def test_i_cannot_toggle_nonexistent_column(self, columns_manager):
|
||||
"""Test that toggling a nonexistent column returns an error message."""
|
||||
result = columns_manager.toggle_column("nonexistent")
|
||||
|
||||
expected = Div("Column 'nonexistent' not found")
|
||||
assert matches(result, expected)
|
||||
|
||||
# =========================================================================
|
||||
# Show Column Details
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_show_column_details_for_existing_column(self, columns_manager):
|
||||
"""Test that show_column_details returns the details form for an existing column."""
|
||||
result = columns_manager.show_column_details("name")
|
||||
|
||||
# Should contain a Form - check by finding form tag in children
|
||||
expected = Form()
|
||||
del(expected.attrs["enctype"]) # hack. We don't know why enctype is added
|
||||
forms = find(result, expected)
|
||||
assert len(forms) == 1, "Should contain exactly one form"
|
||||
|
||||
def test_i_cannot_show_details_for_nonexistent_column(self, columns_manager):
|
||||
"""Test that showing details for nonexistent column returns error message."""
|
||||
result = columns_manager.show_column_details("nonexistent")
|
||||
|
||||
expected = Div("Column 'nonexistent' not found")
|
||||
assert matches(result, expected)
|
||||
|
||||
# =========================================================================
|
||||
# Show All Columns
|
||||
# =========================================================================
|
||||
|
||||
def test_show_all_columns_returns_search_component(self, columns_manager):
|
||||
"""Test that show_all_columns returns a Search component."""
|
||||
result = columns_manager.show_all_columns()
|
||||
|
||||
assert isinstance(result, Search)
|
||||
|
||||
def test_show_all_columns_contains_all_columns(self, columns_manager):
|
||||
"""Test that show_all_columns Search contains all columns."""
|
||||
result = columns_manager.show_all_columns()
|
||||
|
||||
assert len(result.items) == 3
|
||||
|
||||
# =========================================================================
|
||||
# Update Column
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_update_column_title(self, columns_manager):
|
||||
"""Test updating a column's title via client_response."""
|
||||
client_response = {"title": "New Name"}
|
||||
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
assert col_def.title == "New Name"
|
||||
|
||||
def test_i_can_update_column_visibility_via_form(self, columns_manager):
|
||||
"""Test updating column visibility via checkbox form value."""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
assert col_def.visible is True
|
||||
|
||||
# Unchecked checkbox sends nothing, checked sends "on"
|
||||
client_response = {"visible": "off"} # Not "on" means unchecked
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
assert col_def.visible is False
|
||||
|
||||
# Check it back on
|
||||
client_response = {"visible": "on"}
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
assert col_def.visible is True
|
||||
|
||||
def test_i_can_update_column_type(self, columns_manager):
|
||||
"""Test updating a column's type."""
|
||||
client_response = {"type": "Number"}
|
||||
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
assert col_def.type == ColumnType.Number
|
||||
|
||||
def test_i_can_update_column_width(self, columns_manager):
|
||||
"""Test updating a column's width."""
|
||||
client_response = {"width": "200"}
|
||||
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
assert col_def.width == 200
|
||||
|
||||
def test_update_column_saves_state(self, columns_manager, mock_datagrid):
|
||||
"""Test that update_column calls save_state on parent."""
|
||||
mock_datagrid._save_state_called = False
|
||||
|
||||
columns_manager.update_column("name", {"title": "Updated"})
|
||||
|
||||
assert mock_datagrid._save_state_called is True
|
||||
|
||||
def test_update_column_ignores_unknown_attributes(self, columns_manager):
|
||||
"""Test that update_column ignores attributes not in DataGridColumnState."""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
original_title = col_def.title
|
||||
|
||||
client_response = {"unknown_attr": "value", "title": "New Title"}
|
||||
columns_manager.update_column("name", client_response)
|
||||
|
||||
# unknown_attr should be ignored, title should be updated
|
||||
assert col_def.title == "New Title"
|
||||
assert not hasattr(col_def, "unknown_attr")
|
||||
|
||||
def test_i_cannot_update_nonexistent_column(self, columns_manager):
|
||||
"""Test that updating nonexistent column returns mk_all_columns result."""
|
||||
result = columns_manager.update_column("nonexistent", {"title": "Test"})
|
||||
|
||||
# Should return the all columns view (Search component)
|
||||
assert isinstance(result, Search)
|
||||
|
||||
|
||||
class TestDataGridColumnsManagerRender:
|
||||
"""Tests for DataGridColumnsManager HTML rendering."""
|
||||
|
||||
@pytest.fixture
|
||||
def columns_manager(self, mock_datagrid):
|
||||
"""Create a fresh DataGridColumnsManager for render tests."""
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
cm = DataGridColumnsManager(mock_datagrid)
|
||||
yield cm
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
# =========================================================================
|
||||
# Global Structure
|
||||
# =========================================================================
|
||||
|
||||
def test_i_can_render_columns_manager_with_columns(self, columns_manager):
|
||||
"""Test that DataGridColumnsManager renders with correct global structure.
|
||||
|
||||
Why these elements matter:
|
||||
- id: Required for HTMX targeting in commands
|
||||
- Contains Search component: Main content for column list
|
||||
"""
|
||||
html = columns_manager.render()
|
||||
|
||||
expected = Div(
|
||||
TestObject(Search), # Search component
|
||||
id=columns_manager._id,
|
||||
)
|
||||
|
||||
assert matches(html, expected)
|
||||
|
||||
# =========================================================================
|
||||
# mk_column_label
|
||||
# =========================================================================
|
||||
|
||||
def test_column_label_has_checkbox_and_details_navigation(self, columns_manager):
|
||||
"""Test that column label contains checkbox and navigation to details.
|
||||
|
||||
Why these elements matter:
|
||||
- Checkbox (Input type=checkbox): Controls column visibility
|
||||
- Label with column ID: Identifies the column
|
||||
- Chevron icon: Indicates navigation to details
|
||||
- id with tcolman_ prefix: Required for HTMX swap targeting
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
label = columns_manager.mk_column_label(col_def)
|
||||
|
||||
# Should have the correct ID pattern
|
||||
expected = Div(
|
||||
id=f"tcolman_{columns_manager._id}-name",
|
||||
cls=Contains("flex"),
|
||||
)
|
||||
assert matches(label, expected)
|
||||
|
||||
# Should contain a checkbox
|
||||
checkbox = find_one(label, Input(type="checkbox"))
|
||||
assert checkbox is not None
|
||||
|
||||
# Should contain chevron icon for navigation
|
||||
chevron = find_one(label, TestIcon("chevron_right20_regular"))
|
||||
assert chevron is not None
|
||||
|
||||
def test_column_label_checkbox_is_checked_when_visible(self, columns_manager):
|
||||
"""Test that checkbox is checked when column is visible.
|
||||
|
||||
Why this matters:
|
||||
- checked attribute: Reflects current visibility state
|
||||
- User can see which columns are visible
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
assert col_def.visible is True
|
||||
|
||||
label = columns_manager.mk_column_label(col_def)
|
||||
checkbox = find_one(label, Input(type="checkbox"))
|
||||
|
||||
# Checkbox should have checked attribute
|
||||
assert checkbox.attrs.get("checked") is True
|
||||
|
||||
def test_column_label_checkbox_is_unchecked_when_hidden(self, columns_manager):
|
||||
"""Test that checkbox is unchecked when column is hidden.
|
||||
|
||||
Why this matters:
|
||||
- No checked attribute: Indicates column is hidden
|
||||
- Visual feedback for user
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("email")
|
||||
assert col_def.visible is False
|
||||
|
||||
label = columns_manager.mk_column_label(col_def)
|
||||
checkbox = find_one(label, Input(type="checkbox"))
|
||||
|
||||
# Checkbox should not have checked attribute (or it should be False/None)
|
||||
checked = checkbox.attrs.get("checked")
|
||||
assert checked is None or checked is False
|
||||
|
||||
# =========================================================================
|
||||
# mk_column_details
|
||||
# =========================================================================
|
||||
|
||||
def test_column_details_contains_all_form_fields(self, columns_manager):
|
||||
"""Test that column details form contains all required fields.
|
||||
|
||||
Why these elements matter:
|
||||
- col_id field (readonly): Shows column identifier
|
||||
- title field: Editable column display name
|
||||
- visible checkbox: Toggle visibility
|
||||
- type select: Change column type
|
||||
- width input: Set column width
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
details = columns_manager.mk_column_details(col_def)
|
||||
|
||||
# Should contain Form
|
||||
form = Form()
|
||||
del form.attrs["enctype"]
|
||||
form = find_one(details, form)
|
||||
assert form is not None
|
||||
|
||||
# Should contain all required input fields
|
||||
col_id_input = find_one(form, Input(name="col_id"))
|
||||
assert col_id_input is not None
|
||||
assert col_id_input.attrs.get("readonly") is True
|
||||
|
||||
title_input = find_one(form, Input(name="title"))
|
||||
assert title_input is not None
|
||||
|
||||
visible_checkbox = find_one(form, Input(name="visible", type="checkbox"))
|
||||
assert visible_checkbox is not None
|
||||
|
||||
type_select = find_one(form, Select(name="type"))
|
||||
assert type_select is not None
|
||||
|
||||
width_input = find_one(form, Input(name="width", type="number"))
|
||||
assert width_input is not None
|
||||
|
||||
def test_column_details_has_back_button(self, columns_manager):
|
||||
"""Test that column details has a back button to return to all columns.
|
||||
|
||||
Why this matters:
|
||||
- Back navigation: User can return to column list
|
||||
- Chevron left icon: Visual indicator of back action
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
details = columns_manager.mk_column_details(col_def)
|
||||
|
||||
# Should contain back chevron icon
|
||||
back_icon = find_one(details, TestIcon("chevron_left20_regular", wrapper="span"))
|
||||
assert back_icon is not None
|
||||
|
||||
def test_column_details_form_has_fieldset_with_legend(self, columns_manager):
|
||||
"""Test that column details form has a fieldset with legend.
|
||||
|
||||
Why this matters:
|
||||
- Fieldset groups related fields
|
||||
- Legend provides context ("Column details")
|
||||
"""
|
||||
col_def = columns_manager._get_col_def_from_col_id("name")
|
||||
details = columns_manager.mk_column_details(col_def)
|
||||
|
||||
fieldset = find_one(details, Fieldset(legend="Column details"))
|
||||
assert fieldset is not None
|
||||
|
||||
# =========================================================================
|
||||
# mk_all_columns
|
||||
# =========================================================================
|
||||
|
||||
def test_all_columns_uses_search_component(self, columns_manager):
|
||||
"""Test that mk_all_columns returns a Search component.
|
||||
|
||||
Why this matters:
|
||||
- Search component: Enables filtering columns by name
|
||||
- items_names="Columns": Labels the search appropriately
|
||||
"""
|
||||
result = columns_manager.mk_all_columns()
|
||||
|
||||
assert isinstance(result, Search)
|
||||
assert result.items_names == "Columns"
|
||||
|
||||
def test_all_columns_search_has_correct_configuration(self, columns_manager):
|
||||
"""Test that Search component is configured correctly.
|
||||
|
||||
Why these elements matter:
|
||||
- items: Contains all column definitions
|
||||
- get_attr: Extracts col_id for search matching
|
||||
- template: Uses mk_column_label for rendering
|
||||
"""
|
||||
result = columns_manager.mk_all_columns()
|
||||
|
||||
# Should have all 3 columns
|
||||
assert len(result.items) == 3
|
||||
|
||||
# get_attr should return col_id
|
||||
col_def = result.items[0]
|
||||
assert result.get_attr(col_def) == col_def.col_id
|
||||
|
||||
def test_all_columns_renders_all_column_labels(self, columns_manager):
|
||||
"""Test that all columns render produces labels for all columns.
|
||||
|
||||
Why this matters:
|
||||
- All columns visible in list
|
||||
- Each column has its label rendered
|
||||
"""
|
||||
search = columns_manager.mk_all_columns()
|
||||
rendered = search.render()
|
||||
|
||||
# Should find 3 column labels in the results
|
||||
results_div = find_one(rendered, Div(id=f"{search._id}-results"))
|
||||
assert results_div is not None
|
||||
|
||||
# Each column should have a label with tcolman_ prefix
|
||||
for col_id in ["name", "age", "email"]:
|
||||
label = find_one(results_div, Div(id=f"tcolman_{columns_manager._id}-{col_id}"))
|
||||
assert label is not None, f"Column label for '{col_id}' should be present"
|
||||
Reference in New Issue
Block a user