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:**
|
**Test order:**
|
||||||
1. **First test:** Global structure (UTR-11.1)
|
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:**
|
**How to choose:**
|
||||||
1. **Read the source code** to see how the icon is rendered
|
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()`
|
2. If `mk.icon()` wraps the icon in a Div → use `TestIcon()` (default `wrapper="div"`)
|
||||||
3. If the icon is directly included without wrapper → use `TestIconNotStr()`
|
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:**
|
**The `name` parameter:**
|
||||||
- **Exact name**: Use the exact import name (e.g., `TestIcon("panel_right_expand20_regular")`) to validate a specific icon
|
- **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:**
|
**Examples:**
|
||||||
|
|
||||||
```python
|
```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)
|
# Source code: mk.icon(panel_right_expand20_regular, size=20)
|
||||||
# Rendered: <div><NotStr .../></div>
|
# Rendered: <div><svg .../></div>
|
||||||
expected = Header(
|
expected = Header(
|
||||||
Div(
|
Div(
|
||||||
TestIcon("panel_right_expand20_regular"), # ✅ With wrapper
|
TestIcon("panel_right_expand20_regular"), # ✅ wrapper="div" (default)
|
||||||
cls=Contains("flex", "gap-1")
|
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")
|
# Source code: Span(dismiss_circle16_regular, cls="icon")
|
||||||
# Rendered: <span><NotStr .../></span>
|
# Rendered: <span><svg .../></span>
|
||||||
expected = Span(
|
expected = Span(
|
||||||
TestIconNotStr("dismiss_circle16_regular"), # ✅ Without wrapper
|
TestIconNotStr("dismiss_circle16_regular"), # ✅ Without wrapper
|
||||||
cls=Contains("icon")
|
cls=Contains("icon")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Example 3: Verify any wrapped icon
|
# Example 4: Verify any wrapped icon
|
||||||
expected = Div(
|
expected = Div(
|
||||||
TestIcon(""), # Accepts any wrapped icon
|
TestIcon(""), # Accepts any wrapped icon
|
||||||
cls=Contains("icon-wrapper")
|
cls=Contains("icon-wrapper")
|
||||||
@@ -446,7 +462,10 @@ expected = Div(
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Debugging tip:**
|
**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**
|
### **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?
|
**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.
|
**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:
|
2. **Documentation format**: Every render test MUST have a docstring with:
|
||||||
- First line: Brief description of what is being tested
|
- First line: Brief description of what is being tested
|
||||||
- Blank line
|
- 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)
|
- 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`)
|
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**
|
**Prerequisite**
|
||||||
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
|
- **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.6**: Always `Contains()` for `cls` and `style`
|
||||||
- **UTR-11.7**: `TestIcon()` or `TestIconNotStr()` to test icon presence
|
- **UTR-11.7**: `TestIcon()` or `TestIconNotStr()` to test icon presence
|
||||||
- **UTR-11.8**: `TestScript()` for JavaScript
|
- **UTR-11.8**: `TestScript()` for JavaScript
|
||||||
|
- **UTR-11.9**: Remove default `enctype` from `Form()` patterns
|
||||||
|
|
||||||
**How to document**
|
**How to document**
|
||||||
- **UTR-11.9**: Justify the choice of tested elements
|
- **UTR-11.10**: Justify the choice of tested elements
|
||||||
- **UTR-11.10**: Explicit messages for `assert len()`
|
- **UTR-11.11**: Explicit messages for `assert len()`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -601,7 +670,7 @@ assert len(resizers) == 1
|
|||||||
- Reference specific patterns from the documentation
|
- Reference specific patterns from the documentation
|
||||||
- Explain why you chose to test certain elements and not others
|
- Explain why you chose to test certain elements and not others
|
||||||
- Justify the use of predicates vs exact values
|
- 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
|
* 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 {
|
.mf-tooltip-container {
|
||||||
background: var(--color-base-200);
|
background: var(--color-base-200);
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
@@ -1161,3 +1171,19 @@
|
|||||||
.dt2-moving {
|
.dt2-moving {
|
||||||
transition: transform 300ms ease;
|
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
|
// Prevent default only if click was INSIDE a registered element
|
||||||
if (currentMatches.length > 0 && !isInInputContext()) {
|
// Clicks outside should preserve native behavior (checkboxes, buttons, etc.)
|
||||||
|
const anyMatchInside = currentMatches.some(match => match.isInside);
|
||||||
|
if (currentMatches.length > 0 && anyMatchInside && !isInInputContext()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from myfasthtml.controls.Mouse import Mouse
|
|||||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
|
||||||
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
|
||||||
from myfasthtml.controls.helpers import mk
|
from myfasthtml.controls.helpers import mk, icons
|
||||||
from myfasthtml.core.commands import Command
|
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.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
|
||||||
from myfasthtml.core.dbmanager import DbObject
|
from myfasthtml.core.dbmanager import DbObject
|
||||||
@@ -138,6 +138,13 @@ class Commands(BaseCommands):
|
|||||||
self._owner.toggle_columns_manager
|
self._owner.toggle_columns_manager
|
||||||
).htmx(target=None)
|
).htmx(target=None)
|
||||||
|
|
||||||
|
def on_column_changed(self):
|
||||||
|
return Command("OnColumnChanged",
|
||||||
|
"Column definition changed",
|
||||||
|
self._owner,
|
||||||
|
self._owner.on_column_changed
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DataGrid(MultipleInstance):
|
class DataGrid(MultipleInstance):
|
||||||
def __init__(self, parent, settings=None, save_state=None, _id=None):
|
def __init__(self, parent, settings=None, save_state=None, _id=None):
|
||||||
@@ -170,6 +177,8 @@ class DataGrid(MultipleInstance):
|
|||||||
|
|
||||||
# add columns manager
|
# add columns manager
|
||||||
self._columns_manager = DataGridColumnsManager(self)
|
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
|
# other definitions
|
||||||
self._mouse_support = {
|
self._mouse_support = {
|
||||||
@@ -354,7 +363,7 @@ class DataGrid(MultipleInstance):
|
|||||||
def filter(self):
|
def filter(self):
|
||||||
logger.debug("filter")
|
logger.debug("filter")
|
||||||
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
|
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):
|
def on_click(self, combination, is_inside, cell_id):
|
||||||
logger.debug(f"on_click {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()
|
return self.render_partial()
|
||||||
|
|
||||||
|
def on_column_changed(self):
|
||||||
|
logger.debug("on_column_changed")
|
||||||
|
return self.render_partial("table")
|
||||||
|
|
||||||
def change_selection_mode(self):
|
def change_selection_mode(self):
|
||||||
logger.debug(f"change_selection_mode")
|
logger.debug(f"change_selection_mode")
|
||||||
new_state = self._selection_mode_selector.get_state()
|
new_state = self._selection_mode_selector.get_state()
|
||||||
@@ -377,17 +390,27 @@ class DataGrid(MultipleInstance):
|
|||||||
logger.debug(f"toggle_columns_manager")
|
logger.debug(f"toggle_columns_manager")
|
||||||
self._panel.set_right(self._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):
|
def mk_headers(self):
|
||||||
resize_cmd = self.commands.set_column_width()
|
resize_cmd = self.commands.set_column_width()
|
||||||
move_cmd = self.commands.move_column()
|
move_cmd = self.commands.move_column()
|
||||||
|
|
||||||
def _mk_header_name(col_def: DataGridColumnState):
|
def _mk_header_name(col_def: DataGridColumnState):
|
||||||
return Div(
|
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",
|
cls="flex truncate cursor-default",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _mk_header(col_def: DataGridColumnState):
|
def _mk_header(col_def: DataGridColumnState):
|
||||||
|
if not col_def.visible:
|
||||||
|
return None
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
_mk_header_name(col_def),
|
_mk_header_name(col_def),
|
||||||
Div(cls="dt2-resize-handle", data_command_id=resize_cmd.id),
|
Div(cls="dt2-resize-handle", data_command_id=resize_cmd.id),
|
||||||
@@ -397,7 +420,7 @@ class DataGrid(MultipleInstance):
|
|||||||
cls="dt2-cell dt2-resizable flex",
|
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(
|
return Div(
|
||||||
*[_mk_header(col_def) for col_def in self._state.columns],
|
*[_mk_header(col_def) for col_def in self._state.columns],
|
||||||
cls=header_class,
|
cls=header_class,
|
||||||
@@ -470,7 +493,7 @@ class DataGrid(MultipleInstance):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if not col_def.visible:
|
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]
|
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)
|
content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower)
|
||||||
@@ -509,7 +532,7 @@ class DataGrid(MultipleInstance):
|
|||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def mk_body_container(self):
|
def mk_body_wrapper(self):
|
||||||
return Div(
|
return Div(
|
||||||
self.mk_body(),
|
self.mk_body(),
|
||||||
cls="dt2-body-container",
|
cls="dt2-body-container",
|
||||||
@@ -535,28 +558,12 @@ class DataGrid(MultipleInstance):
|
|||||||
id=f"tf_{self._id}"
|
id=f"tf_{self._id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def mk_table(self):
|
def mk_table_wrapper(self):
|
||||||
return Div(
|
return Div(
|
||||||
self.mk_selection_manager(),
|
self.mk_selection_manager(),
|
||||||
|
|
||||||
# Grid table with header, body, footer
|
self.mk_table(),
|
||||||
Div(
|
|
||||||
# Header container - no scroll
|
|
||||||
Div(
|
|
||||||
self.mk_headers(),
|
|
||||||
cls="dt2-header-container"
|
|
||||||
),
|
|
||||||
|
|
||||||
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
|
# Custom scrollbars overlaid
|
||||||
Div(
|
Div(
|
||||||
# Vertical scrollbar wrapper (right side)
|
# Vertical scrollbar wrapper (right side)
|
||||||
@@ -575,6 +582,26 @@ class DataGrid(MultipleInstance):
|
|||||||
id=f"tw_{self._id}"
|
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):
|
def mk_selection_manager(self):
|
||||||
|
|
||||||
extra_attr = {
|
extra_attr = {
|
||||||
@@ -667,7 +694,7 @@ class DataGrid(MultipleInstance):
|
|||||||
mk.icon(settings16_regular, command=self.commands.toggle_columns_manager(), tooltip="Show sidebar"),
|
mk.icon(settings16_regular, command=self.commands.toggle_columns_manager(), tooltip="Show sidebar"),
|
||||||
cls="flex"),
|
cls="flex"),
|
||||||
cls="flex items-center justify-between mb-2"),
|
cls="flex items-center justify-between mb-2"),
|
||||||
self._panel.set_main(self.mk_table()),
|
self._panel.set_main(self.mk_table_wrapper()),
|
||||||
Script(f"initDataGrid('{self._id}');"),
|
Script(f"initDataGrid('{self._id}');"),
|
||||||
Mouse(self, combinations=self._mouse_support),
|
Mouse(self, combinations=self._mouse_support),
|
||||||
id=self._id,
|
id=self._id,
|
||||||
@@ -675,7 +702,7 @@ class DataGrid(MultipleInstance):
|
|||||||
style="height: 100%; grid-template-rows: auto 1fr;"
|
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
|
:param fragment: cell | body
|
||||||
@@ -689,10 +716,15 @@ class DataGrid(MultipleInstance):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if fragment == "body":
|
if fragment == "body":
|
||||||
body_container = self.mk_body_container()
|
body_container = self.mk_body_wrapper()
|
||||||
body_container.attrs.update(extra_attr)
|
body_container.attrs.update(extra_attr)
|
||||||
res.append(body_container)
|
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())
|
res.append(self.mk_selection_manager())
|
||||||
|
|
||||||
return tuple(res)
|
return tuple(res)
|
||||||
|
|||||||
@@ -1,33 +1,190 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from fasthtml.components import *
|
from fasthtml.components import *
|
||||||
|
|
||||||
|
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||||
from myfasthtml.controls.Search import Search
|
from myfasthtml.controls.Search import Search
|
||||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState
|
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.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):
|
class DataGridColumnsManager(MultipleInstance):
|
||||||
def __init__(self, parent, _id=None):
|
def __init__(self, parent, _id=None):
|
||||||
super().__init__(parent, _id=_id)
|
super().__init__(parent, _id=_id)
|
||||||
|
self.commands = Commands(self)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def columns(self):
|
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(
|
return Div(
|
||||||
Input(type="checkbox", checked=col_def.visible, cls="ml-2"),
|
mk.mk(
|
||||||
Label(col_def.col_id, cls="ml-2"),
|
Input(type="checkbox", cls="checkbox checkbox-sm", checked=col_def.visible),
|
||||||
cls="flex mb-1",
|
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,
|
return Search(self,
|
||||||
items_names="Columns",
|
items_names="Columns",
|
||||||
items=self.columns,
|
items=self.columns,
|
||||||
get_attr=lambda x: x.col_id,
|
get_attr=lambda x: x.col_id,
|
||||||
template=self.mk_column,
|
template=self.mk_column_label,
|
||||||
max_height=None
|
max_height=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
return Div(
|
||||||
|
self.mk_all_columns(),
|
||||||
|
id=self._id,
|
||||||
|
)
|
||||||
|
|
||||||
def __ft__(self):
|
def __ft__(self):
|
||||||
return self.render()
|
return self.render()
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ from fasthtml.components import *
|
|||||||
|
|
||||||
from myfasthtml.core.bindings import Binding
|
from myfasthtml.core.bindings import Binding
|
||||||
from myfasthtml.core.commands import Command, CommandTemplate
|
from myfasthtml.core.commands import Command, CommandTemplate
|
||||||
|
from myfasthtml.core.constants import ColumnType
|
||||||
from myfasthtml.core.utils import merge_classes
|
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:
|
class Ids:
|
||||||
@@ -96,7 +103,7 @@ class mk:
|
|||||||
command: Command | CommandTemplate = None,
|
command: Command | CommandTemplate = None,
|
||||||
binding: Binding = None,
|
binding: Binding = None,
|
||||||
**kwargs):
|
**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
|
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}")
|
text_part = Span(text, cls=f"text-{size}")
|
||||||
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
|
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_command(ft, command) if command else ft
|
||||||
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
|
ft = mk.manage_binding(ft, binding, init_binding=init_binding) if binding else ft
|
||||||
return 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):
|
class TestIcon(TestObject):
|
||||||
def __init__(self, name: Optional[str] = '', command=None):
|
def __init__(self, name: Optional[str] = '', wrapper="div", command=None):
|
||||||
super().__init__("div")
|
super().__init__(wrapper)
|
||||||
|
self.wrapper = wrapper
|
||||||
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
||||||
self.children = [
|
self.children = [
|
||||||
TestObject(NotStr, s=Regex(f'<svg name="\\w+-{self.name}'))
|
TestObject(NotStr, s=Regex(f'<svg name="\\w+-{self.name}'))
|
||||||
@@ -196,7 +197,7 @@ class TestIcon(TestObject):
|
|||||||
self.attrs |= command.get_htmx_params()
|
self.attrs |= command.get_htmx_params()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f'<div><svg name="{self.name}" .../></div>'
|
return f'<{self.wrapper}><svg name="{self.name}" .../></{self.wrapper}>'
|
||||||
|
|
||||||
|
|
||||||
class TestIconNotStr(TestObject):
|
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