diff --git a/.claude/commands/unit-tester.md b/.claude/commands/unit-tester.md
index 419b5e9..d91c44d 100644
--- a/.claude/commands/unit-tester.md
+++ b/.claude/commands/unit-tester.md
@@ -258,7 +258,7 @@ def test_i_can_render_component_with_no_data(self, component):
**Test order:**
1. **First test:** Global structure (UTR-11.1)
-2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.10)
+2. **Following tests:** Details of each section (UTR-11.2 to UTR-11.11)
---
@@ -410,8 +410,19 @@ expected = Div(style="width: 250px; overflow: hidden; display: flex;")
**How to choose:**
1. **Read the source code** to see how the icon is rendered
-2. If `mk.icon()` or equivalent wraps the icon in a Div → use `TestIcon()`
-3. If the icon is directly included without wrapper → use `TestIconNotStr()`
+2. If `mk.icon()` wraps the icon in a Div → use `TestIcon()` (default `wrapper="div"`)
+3. If `mk.label(..., icon=...)` wraps the icon in a Span → use `TestIcon(..., wrapper="span")`
+4. If the icon is directly included without wrapper → use `TestIconNotStr()`
+
+**The `wrapper` parameter:**
+
+Different `mk` helpers use different wrappers for icons:
+
+| Helper method | Wrapper element | TestIcon usage |
+|---------------|-----------------|----------------|
+| `mk.icon(my_icon)` | `
` | `TestIcon("name")` |
+| `mk.label("Text", icon=my_icon)` | `` | `TestIcon("name", wrapper="span")` |
+| Direct: `Div(my_icon)` | none | `TestIconNotStr("name")` |
**The `name` parameter:**
- **Exact name**: Use the exact import name (e.g., `TestIcon("panel_right_expand20_regular")`) to validate a specific icon
@@ -420,25 +431,30 @@ expected = Div(style="width: 250px; overflow: hidden; display: flex;")
**Examples:**
```python
-# Example 1: Wrapped icon (typically with mk.icon())
+# Example 1: Icon via mk.icon() - wrapper is Div (default)
# Source code: mk.icon(panel_right_expand20_regular, size=20)
-# Rendered:
+# Rendered:
expected = Header(
Div(
- TestIcon("panel_right_expand20_regular"), # ✅ With wrapper
+ TestIcon("panel_right_expand20_regular"), # ✅ wrapper="div" (default)
cls=Contains("flex", "gap-1")
)
)
-# Example 2: Direct icon (used without helper)
+# Example 2: Icon via mk.label() - wrapper is Span
+# Source code: mk.label("Back", icon=chevron_left20_regular, command=...)
+# Rendered:
+back_icon = find_one(details, TestIcon("chevron_left20_regular", wrapper="span")) # ✅ wrapper="span"
+
+# Example 3: Direct icon (used without helper)
# Source code: Span(dismiss_circle16_regular, cls="icon")
-# Rendered:
+# Rendered:
expected = Span(
TestIconNotStr("dismiss_circle16_regular"), # ✅ Without wrapper
cls=Contains("icon")
)
-# Example 3: Verify any wrapped icon
+# Example 4: Verify any wrapped icon
expected = Div(
TestIcon(""), # Accepts any wrapped icon
cls=Contains("icon-wrapper")
@@ -446,7 +462,10 @@ expected = Div(
```
**Debugging tip:**
-If your test fails with `TestIcon()`, try `TestIconNotStr()` and vice-versa. The error message will show you the actual structure.
+If your test fails with `TestIcon()`:
+1. Check if the wrapper is `` instead of `
` → try `wrapper="span"`
+2. Check if there's no wrapper at all → try `TestIconNotStr()`
+3. The error message will show you the actual structure
---
@@ -467,11 +486,60 @@ expected = Script("(function() { const id = '...'; initResizer(id); })()")
---
+#### **UTR-11.9: Remove default `enctype` attribute when searching for Form elements**
+
+**Principle:** FastHTML's `Form()` component automatically adds `enctype="multipart/form-data"` as a default attribute. When using `find()` or `find_one()` to search for a Form, you must remove this attribute from the expected pattern.
+
+**Why:** The actual Form in your component may not have this attribute, causing the match to fail.
+
+**Problem:**
+```python
+# ❌ FAILS - Form() has default enctype that may not exist in actual form
+form = find_one(details, Form()) # AssertionError: Found 0 elements
+```
+
+**Solution:**
+```python
+# ✅ WORKS - Remove the default enctype attribute
+expected_form = Form()
+del expected_form.attrs["enctype"]
+form = find_one(details, expected_form)
+```
+
+**Alternative - Search with specific attribute:**
+```python
+# ✅ ALSO WORKS - Search by a known attribute
+form = find_one(details, Form(cls=Contains("my-form-class")))
+# But still need to delete enctype if Form() is used as pattern
+```
+
+**Complete example:**
+```python
+def test_column_details_contains_form(self, component):
+ """Test that column details contains a form with required fields."""
+ details = component.mk_column_details(col_def)
+
+ # Create Form pattern and remove default enctype
+ expected_form = Form()
+ del expected_form.attrs["enctype"]
+
+ form = find_one(details, expected_form)
+ assert form is not None
+
+ # Now search within the found form
+ title_input = find_one(form, Input(name="title"))
+ assert title_input is not None
+```
+
+**Note:** This is a FastHTML-specific behavior. Always check for similar default attributes when tests fail unexpectedly with "Found 0 elements".
+
+---
+
### **HOW TO DOCUMENT TESTS**
---
-#### **UTR-11.9: Justify the choice of tested elements**
+#### **UTR-11.10: Justify the choice of tested elements**
**Principle:** In the test documentation section (after the description docstring), explain **why each tested element or attribute was chosen**. What makes it important for the functionality?
@@ -518,7 +586,7 @@ def test_left_drawer_is_rendered_when_open(self, layout):
---
-#### **UTR-11.10: Count tests with explicit messages**
+#### **UTR-11.11: Count tests with explicit messages**
**Principle:** When you count elements with `assert len()`, ALWAYS add an explicit message explaining why this number is expected.
@@ -548,7 +616,7 @@ assert len(resizers) == 1
2. **Documentation format**: Every render test MUST have a docstring with:
- First line: Brief description of what is being tested
- Blank line
- - Justification section explaining why tested elements matter (see UTR-11.9)
+ - Justification section explaining why tested elements matter (see UTR-11.10)
- List of important elements/attributes being tested with explanations (in English)
3. **No inline comments**: Do NOT add comments on each line of the expected structure (except for structural clarification in global layout tests like `# left drawer`)
@@ -572,7 +640,7 @@ assert len(resizers) == 1
---
-#### **Summary: The 11 UTR-11 sub-rules**
+#### **Summary: The 12 UTR-11 sub-rules**
**Prerequisite**
- **UTR-11.0**: ⭐⭐⭐ Read `docs/testing_rendered_components.md` (MANDATORY)
@@ -590,10 +658,11 @@ assert len(resizers) == 1
- **UTR-11.6**: Always `Contains()` for `cls` and `style`
- **UTR-11.7**: `TestIcon()` or `TestIconNotStr()` to test icon presence
- **UTR-11.8**: `TestScript()` for JavaScript
+- **UTR-11.9**: Remove default `enctype` from `Form()` patterns
**How to document**
-- **UTR-11.9**: Justify the choice of tested elements
-- **UTR-11.10**: Explicit messages for `assert len()`
+- **UTR-11.10**: Justify the choice of tested elements
+- **UTR-11.11**: Explicit messages for `assert len()`
---
@@ -601,7 +670,7 @@ assert len(resizers) == 1
- Reference specific patterns from the documentation
- Explain why you chose to test certain elements and not others
- Justify the use of predicates vs exact values
-- Always include justification documentation (see UTR-11.9)
+- Always include justification documentation (see UTR-11.10)
---
diff --git a/src/myfasthtml/assets/myfasthtml.css b/src/myfasthtml/assets/myfasthtml.css
index 74d2127..cff9889 100644
--- a/src/myfasthtml/assets/myfasthtml.css
+++ b/src/myfasthtml/assets/myfasthtml.css
@@ -59,6 +59,16 @@
* Compatible with DaisyUI 5
*/
+.mf-button {
+ border-radius: 0.375rem;
+ transition: background-color 0.15s ease;
+}
+
+.mf-button:hover {
+ background-color: var(--color-base-300);
+}
+
+
.mf-tooltip-container {
background: var(--color-base-200);
padding: 5px 10px;
diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py
index e046948..14efe7f 100644
--- a/src/myfasthtml/controls/DataGrid.py
+++ b/src/myfasthtml/controls/DataGrid.py
@@ -178,6 +178,7 @@ class DataGrid(MultipleInstance):
# add columns manager
self._columns_manager = DataGridColumnsManager(self)
self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed())
+ self._columns_manager.bind_command("UpdateColumn", self.commands.on_column_changed())
# other definitions
self._mouse_support = {
diff --git a/src/myfasthtml/controls/DataGridColumnsManager.py b/src/myfasthtml/controls/DataGridColumnsManager.py
index 636f2b0..1954cda 100644
--- a/src/myfasthtml/controls/DataGridColumnsManager.py
+++ b/src/myfasthtml/controls/DataGridColumnsManager.py
@@ -7,8 +7,9 @@ from myfasthtml.controls.Search import Search
from myfasthtml.controls.datagrid_objects import DataGridColumnState
from myfasthtml.controls.helpers import icons, mk
from myfasthtml.core.commands import Command
+from myfasthtml.core.constants import ColumnType
from myfasthtml.core.instances import MultipleInstance
-from myfasthtml.icons.fluent_p1 import chevron_right20_regular
+from myfasthtml.icons.fluent_p1 import chevron_right20_regular, chevron_left20_regular
logger = logging.getLogger("DataGridColumnsManager")
@@ -20,6 +21,27 @@ class Commands(BaseCommands):
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):
@@ -31,41 +53,138 @@ class DataGridColumnsManager(MultipleInstance):
def columns(self):
return self._parent.get_state().columns
- def toggle_column(self, col_id):
- logger.debug(f"toggle_column {col_id=}")
+ 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 = cols_defs[0]
col_def.visible = not col_def.visible
self._parent.save_state()
- return self.mk_column(col_def)
+ return self.mk_column_label(col_def)
- def mk_column(self, col_def: DataGridColumnState):
+ 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(
mk.mk(
Input(type="checkbox", cls="checkbox checkbox-sm", checked=col_def.visible),
command=self.commands.toggle_column(col_def.col_id)
),
- 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"
+ mk.mk(
+ Div(
+ Div(mk.label(col_def.col_id, icon=icons.get(col_def.type, None), cls="ml-2")),
+ Div(mk.icon(chevron_right20_regular), cls="mr-2"),
+ cls="dt2-column-manager-label"
+ ),
+ command=self.commands.show_column_details(col_def.col_id)
),
cls="flex mb-1 items-center",
id=f"tcolman_{self._id}-{col_def.col_id}"
)
- def render(self):
+ def mk_column_details(self, col_def: DataGridColumnState):
+ size = "sm"
+ return Div(
+ mk.label("Back", icon=chevron_left20_regular, command=self.commands.show_all_columns()),
+ Form(
+ Fieldset(
+ Label("Column Id"),
+ Input(name="col_id",
+ cls=f"input input-{size}",
+ value=col_def.col_id,
+ readonly=True),
+
+ Label("Title"),
+ Input(name="title",
+ cls=f"input input-{size}",
+ value=col_def.title),
+
+ Label("Visible"),
+ Input(name="visible",
+ type="checkbox",
+ cls=f"checkbox checkbox-{size}",
+ checked="true" if col_def.visible else None),
+
+ Label("type"),
+ Select(
+ *[Option(option.value, value=option.value, selected=option == col_def.type) for option in ColumnType],
+ name="type",
+ cls=f"select select-{size}",
+ value=col_def.title,
+ ),
+
+ Label("Width"),
+ Input(name="width",
+ type="number",
+ cls=f"input input-{size}",
+ value=col_def.width),
+
+ legend="Column details",
+ cls="fieldset border-base-300 rounded-box"
+ ),
+ mk.dialog_buttons(on_ok=self.commands.update_column(col_def.col_id),
+ on_cancel=self.commands.show_all_columns()),
+ cls="mb-1",
+ ),
+ )
+
+ def mk_all_columns(self):
return Search(self,
items_names="Columns",
items=self.columns,
get_attr=lambda x: x.col_id,
- template=self.mk_column,
+ template=self.mk_column_label,
max_height=None
)
+ def render(self):
+ return Div(
+ self.mk_all_columns(),
+ id=self._id,
+ )
+
def __ft__(self):
return self.render()
diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py
index 790133d..a410dcc 100644
--- a/src/myfasthtml/controls/helpers.py
+++ b/src/myfasthtml/controls/helpers.py
@@ -1,4 +1,3 @@
-from fastcore.xml import FT
from fasthtml.components import *
from myfasthtml.core.bindings import Binding
@@ -104,7 +103,7 @@ class mk:
command: Command | CommandTemplate = None,
binding: Binding = None,
**kwargs):
- merged_cls = merge_classes("flex truncate items-center", cls, kwargs)
+ merged_cls = merge_classes("flex truncate items-center", "mf-button" if command else None, cls, kwargs)
icon_part = Span(icon, cls=f"mf-icon-{mk.convert_size(size)} mr-1") if icon else None
text_part = Span(text, cls=f"text-{size}")
return mk.mk(Label(icon_part, text_part, cls=merged_cls, **kwargs), command=command, binding=binding)
diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py
index a143d5e..24b84ba 100644
--- a/src/myfasthtml/test/matcher.py
+++ b/src/myfasthtml/test/matcher.py
@@ -186,8 +186,9 @@ class TestObject:
class TestIcon(TestObject):
- def __init__(self, name: Optional[str] = '', command=None):
- super().__init__("div")
+ def __init__(self, name: Optional[str] = '', wrapper="div", command=None):
+ super().__init__(wrapper)
+ self.wrapper = wrapper
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
self.children = [
TestObject(NotStr, s=Regex(f'
'
+ return f'<{self.wrapper}>{self.wrapper}>'
class TestIconNotStr(TestObject):
diff --git a/tests/controls/test_datagrid_columns_manager.py b/tests/controls/test_datagrid_columns_manager.py
new file mode 100644
index 0000000..e0d3912
--- /dev/null
+++ b/tests/controls/test_datagrid_columns_manager.py
@@ -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"