2 Commits

8 changed files with 837 additions and 58 deletions

View File

@@ -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)
--- ---

View File

@@ -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);
}

View File

@@ -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();
} }

View File

@@ -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
@@ -137,6 +137,13 @@ class Commands(BaseCommands):
self._owner, self._owner,
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):
@@ -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)

View File

@@ -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()

View File

@@ -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,
}

View File

@@ -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):

View 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"