From 05d4e5cd89df048df6677f9c675b00820c5b2d68 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 25 Jan 2026 21:40:32 +0100 Subject: [PATCH] Working version of DataGridColumnsManager.py where columns can be updated --- .claude/commands/unit-tester.md | 103 +++- src/myfasthtml/assets/myfasthtml.css | 10 + src/myfasthtml/controls/DataGrid.py | 1 + .../controls/DataGridColumnsManager.py | 143 +++++- src/myfasthtml/controls/helpers.py | 3 +- src/myfasthtml/test/matcher.py | 7 +- .../controls/test_datagrid_columns_manager.py | 469 ++++++++++++++++++ 7 files changed, 702 insertions(+), 34 deletions(-) create mode 100644 tests/controls/test_datagrid_columns_manager.py 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}>' 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"