2 Commits

Author SHA1 Message Date
853bc4abae Added keyboard navigation support 2026-03-16 22:43:45 +01:00
2fcc225414 Added some unit tests for the grid 2026-03-16 21:46:19 +01:00
5 changed files with 530 additions and 120 deletions

118
docs/Datagrid Tests.md Normal file
View File

@@ -0,0 +1,118 @@
# DataGrid Tests — Backlog
Source file: `tests/controls/test_datagrid.py`
Legend: ✅ Done — ⬜ Pending
---
## TestDataGridBehaviour
### Edition flow
| # | Status | Test | Description |
|---|--------|-----------------------------------------------------------|----------------------------------------------------------|
| 1 | ⬜ | `test_i_can_convert_edition_value_for_number` | `"3.14"``float`, `"5"``int` |
| 2 | ⬜ | `test_i_can_convert_edition_value_for_bool` | `"true"`, `"1"`, `"yes"``True`; others → `False` |
| 3 | ⬜ | `test_i_can_convert_edition_value_for_text` | String value returned unchanged |
| 4 | ⬜ | `test_i_can_handle_start_edition` | Sets `edition.under_edition` and returns a cell render |
| 5 | ⬜ | `test_i_cannot_handle_start_edition_when_already_editing` | Second call while `under_edition` is set is a no-op |
| 6 | ⬜ | `test_i_can_handle_save_edition` | Writes value to data service and clears `under_edition` |
| 7 | ⬜ | `test_i_cannot_handle_save_edition_when_not_editing` | Returns partial render without touching the data service |
### Column management
| # | Status | Test | Description |
|----|--------|---------------------------------------------------------|----------------------------------------------------------------|
| 8 | ⬜ | `test_i_can_add_new_column` | Appends column to `_state.columns` and `_columns` |
| 9 | ⬜ | `test_i_can_handle_columns_reorder` | Reorders `_state.columns` according to provided list |
| 10 | ⬜ | `test_i_can_handle_columns_reorder_ignores_unknown_ids` | Unknown IDs skipped; known columns not in list appended at end |
### Mouse selection
| # | Status | Test | Description |
|----|--------|-------------------------------------------------|---------------------------------------------------------------|
| 11 | ⬜ | `test_i_can_on_mouse_selection_sets_range` | Sets `extra_selected` with `("range", ...)` from two cell IDs |
| 12 | ⬜ | `test_i_cannot_on_mouse_selection_when_outside` | `is_inside=False` leaves `extra_selected` unchanged |
### Key pressed
| # | Status | Test | Description |
|----|--------|--------------------------------------------------|----------------------------------------------------------------------------------------------|
| 13 | ⬜ | `test_i_can_on_key_pressed_enter_starts_edition` | `enter` on selected cell enters edition when `enable_edition=True` and nothing under edition |
### Click
| # | Status | Test | Description |
|----|--------|---------------------------------------------------|-----------------------------------------------------------------|
| 14 | ⬜ | `test_i_can_on_click_second_click_enters_edition` | Second click on already-selected cell triggers `_enter_edition` |
### Filtering / sorting
| # | Status | Test | Description |
|----|--------|--------------------------------------------|-------------------------------------------------------------------------------------|
| 15 | ⬜ | `test_i_can_filter_grid` | `filter()` updates `_state.filtered`; filtered DataFrame excludes non-matching rows |
| 16 | ⬜ | `test_i_can_apply_sort` | `_apply_sort` returns rows in correct order when a sort definition is present |
| 17 | ⬜ | `test_i_can_apply_filter_by_column_values` | Column filter (non-FILTER_INPUT) keeps only matching rows |
### Format rules priority
| # | Status | Test | Description |
|----|--------|----------------------------------------------------------------------|------------------------------------------------------------------|
| 18 | ⬜ | `test_i_can_get_format_rules_cell_level_takes_priority` | Cell format overrides row, column and table format |
| 19 | ⬜ | `test_i_can_get_format_rules_row_level_takes_priority_over_column` | Row format overrides column and table when no cell format |
| 20 | ⬜ | `test_i_can_get_format_rules_column_level_takes_priority_over_table` | Column format overrides table when no cell or row format |
| 21 | ⬜ | `test_i_can_get_format_rules_falls_back_to_table_format` | Table format returned when no cell, row or column format defined |
---
## TestDataGridRender
### Table structure
| # | Status | Test | Description |
|----|--------|------------------------------------------|-------------------------------------------------------------------------------------------|
| 22 | ✅ | `test_i_can_render_table_wrapper` | ID `tw_{id}`, class `dt2-table-wrapper`, 3 sections: selection manager, table, scrollbars |
| 23 | ✅ | `test_i_can_render_table` | ID `t_{id}`, class `dt2-table`, 3 containers: header, body wrapper, footer |
| 24 | ✅ | `test_i_can_render_table_has_scrollbars` | Scrollbars overlay contains vertical and horizontal tracks |
### render_partial fragments
| # | Status | Test | Description |
|----|--------|---------------------------------------------------|--------------------------------------------------------------------------------------|
| 25 | ✅ | `test_i_can_render_partial_body` | Returns `(selection_manager, body_wrapper)` — body wrapper has `hx-on::after-settle` |
| 26 | ✅ | `test_i_can_render_partial_table` | Returns `(selection_manager, table)` — table has `hx-on::after-settle` |
| 27 | ✅ | `test_i_can_render_partial_header` | Returns header with `hx-on::after-settle` containing `setColumnWidth` |
| 28 | ✅ | `test_i_can_render_partial_cell_by_pos` | Returns `(selection_manager, cell)` for a specific `(col, row)` position |
| 29 | ✅ | `test_i_can_render_partial_cell_with_no_position` | Returns only `(selection_manager,)` when no `pos` or `cell_id` given |
### Edition cell
| # | Status | Test | Description |
|----|--------|-----------------------------------------------|----------------------------------------------------------------------------------------------------------|
| 30 | ⬜ | `test_i_can_render_body_cell_in_edition_mode` | When `edition.under_edition` matches, `mk_body_cell` returns an input cell with class `dt2-cell-edition` |
### Cell content — search highlighting
| # | Status | Test | Description |
|----|--------|-----------------------------------------------------------------------------|-----------------------------------------------------------------|
| 31 | ⬜ | `test_i_can_render_body_cell_content_with_search_highlight` | Matching keyword produces a `Span` with class `dt2-highlight-1` |
| 32 | ⬜ | `test_i_can_render_body_cell_content_with_no_highlight_when_keyword_absent` | Non-matching keyword produces no `dt2-highlight-1` span |
### Footer
| # | Status | Test | Description |
|----|--------|-----------------------------------------------------------|--------------------------------------------------------------------------|
| 33 | ⬜ | `test_i_can_render_footers_wrapper` | `mk_footers` renders with ID `tf_{id}` and class `dt2-footer` |
| 34 | ⬜ | `test_i_can_render_aggregation_cell_sum` | `mk_aggregation_cell` with `FooterAggregation.Sum` renders the sum value |
| 35 | ⬜ | `test_i_cannot_render_aggregation_cell_for_hidden_column` | Hidden column returns `Div(cls="dt2-col-hidden")` |
---
## Summary
| Class | Total | ✅ Done | ⬜ Pending |
|-------------------------|--------|--------|-----------|
| `TestDataGridBehaviour` | 21 | 0 | 21 |
| `TestDataGridRender` | 14 | 8 | 6 |
| **Total** | **35** | **8** | **27** |

View File

@@ -142,6 +142,10 @@
outline-offset: -3px; /* Ensure the outline is snug to the cell */
}
.grid:focus {
outline: none;
}
.dt2-cell:hover,
.dt2-selected-cell {
background-color: var(--color-selection);

View File

@@ -688,6 +688,7 @@ function updateDatagridSelection(datagridId) {
});
// Loop through the children of the selection manager
let hasFocusedCell = false;
Array.from(selectionManager.children).forEach((selection) => {
const selectionType = selection.getAttribute('selection-type');
const elementId = selection.getAttribute('element-id');
@@ -697,6 +698,8 @@ function updateDatagridSelection(datagridId) {
if (cellElement) {
cellElement.classList.add('dt2-selected-focus');
cellElement.style.userSelect = 'text';
cellElement.focus({ preventScroll: true });
hasFocusedCell = true;
}
} else if (selectionType === 'cell') {
const cellElement = document.getElementById(`${elementId}`);
@@ -744,6 +747,11 @@ function updateDatagridSelection(datagridId) {
}
}
});
if (!hasFocusedCell) {
const grid = document.getElementById(datagridId);
if (grid) grid.focus({ preventScroll: true });
}
}
/**

View File

@@ -227,6 +227,13 @@ class Commands(BaseCommands):
class DataGrid(MultipleInstance):
_ARROW_KEY_DIRECTIONS = {
"arrowright": "right",
"arrowleft": "left",
"arrowdown": "down",
"arrowup": "up",
}
def __init__(self, parent, conf=None, save_state=None, _id=None):
super().__init__(parent, _id=_id)
name, namespace = (conf.name, conf.namespace) if conf else ("No name", "__default__")
@@ -311,6 +318,10 @@ class DataGrid(MultipleInstance):
self._key_support = {
"esc": {"command": self.commands.on_key_pressed(), "require_inside": False},
"enter": {"command": self.commands.on_key_pressed(), "require_inside": True},
"arrowup": {"command": self.commands.on_key_pressed(), "require_inside": True},
"arrowdown": {"command": self.commands.on_key_pressed(), "require_inside": True},
"arrowleft": {"command": self.commands.on_key_pressed(), "require_inside": True},
"arrowright": {"command": self.commands.on_key_pressed(), "require_inside": True},
}
logger.debug(f"DataGrid '{self.get_table_name()}' with id='{self._id}' created.")
@@ -387,6 +398,37 @@ class DataGrid(MultipleInstance):
else:
return f"tcell_{self._id}-{pos[0]}-{pos[1]}"
def _get_navigable_col_positions(self) -> list[int]:
return [i for i, col in enumerate(self._columns)
if col.visible and col.type != ColumnType.RowSelection_]
def _get_visible_row_indices(self) -> list[int]:
df = self._get_filtered_df()
return list(df.index) if df is not None else []
def _navigate(self, pos: tuple, direction: str) -> tuple:
col_pos, row_index = pos
navigable_cols = self._get_navigable_col_positions()
visible_rows = self._get_visible_row_indices()
if not navigable_cols or not visible_rows:
return pos
if direction == "right":
next_cols = [c for c in navigable_cols if c > col_pos]
return (next_cols[0], row_index) if next_cols else pos
elif direction == "left":
prev_cols = [c for c in navigable_cols if c < col_pos]
return (prev_cols[-1], row_index) if prev_cols else pos
elif direction == "down":
next_rows = [r for r in visible_rows if r > row_index]
return (col_pos, next_rows[0]) if next_rows else pos
elif direction == "up":
prev_rows = [r for r in visible_rows if r < row_index]
return (col_pos, prev_rows[-1]) if prev_rows else pos
return pos
def _get_pos_from_element_id(self, element_id):
if element_id is None:
return None
@@ -650,6 +692,13 @@ class DataGrid(MultipleInstance):
self._state.selection.selected and
self._state.edition.under_edition is None):
return self._enter_edition(self._state.selection.selected)
elif combination in self._ARROW_KEY_DIRECTIONS:
current_pos = (self._state.selection.selected
or self._state.selection.last_selected
or (0, 0))
direction = self._ARROW_KEY_DIRECTIONS[combination]
new_pos = self._navigate(current_pos, direction)
self._update_current_position(new_pos)
return self.render_partial()
@@ -974,6 +1023,7 @@ class DataGrid(MultipleInstance):
data_tooltip=str(value),
style=f"width:{col_def.width}px;",
id=self._get_element_id_from_pos("cell", (col_pos, row_index)),
tabindex="-1",
cls=merge_classes("dt2-cell", "dt2-last-cell" if is_last else None))
def mk_row(self, row_index, filter_keyword_lower, len_columns_1):
@@ -1193,7 +1243,8 @@ class DataGrid(MultipleInstance):
),
id=self._id,
cls="grid",
style="height: 100%; grid-template-rows: auto 1fr;"
style="height: 100%; grid-template-rows: auto 1fr;",
tabindex="-1"
)
def render_partial(self, fragment="cell", **kwargs):

View File

@@ -79,14 +79,6 @@ def datagrid_with_full_data(datagrids_manager):
return dg
@pytest.fixture
def datagrid_no_edition(datagrid_with_data):
"""DataGrid with edition disabled (no RowSelection column, no add-column button)."""
dg = datagrid_with_data
dg._settings.enable_edition = False
dg._init_columns()
return dg
class TestDataGridBehaviour:
def test_i_can_create_empty_datagrid(self, datagrids_manager):
@@ -150,24 +142,20 @@ class TestDataGridBehaviour:
# Element ID Parsing
# ------------------------------------------------------------------
def test_i_can_get_pos_from_cell_element_id(self, datagrid):
"""Test that _get_pos_from_element_id correctly parses (col, row) from a cell ID.
@pytest.mark.parametrize("element_id_template, expected", [
("tcell_{id}-3-7", (3, 7)),
("trow_{id}-5", None),
(None, None),
])
def test_i_can_get_pos_from_element_id(self, datagrid, element_id_template, expected):
"""Test that _get_pos_from_element_id returns the correct (col, row) position or None.
The position tuple (col, row) is used for cell navigation and selection
state tracking. Correct parsing is required for keyboard navigation and
mouse selection to target the right cell.
- Cell IDs ('tcell_…') carry (col, row) indices required for cell navigation.
- Row IDs ('trow_…') have no cell position; None signals no cell can be derived.
- None input is a safe no-op; callers must handle it without raising.
"""
element_id = f"tcell_{datagrid._id}-3-7"
assert datagrid._get_pos_from_element_id(element_id) == (3, 7)
def test_i_can_get_pos_returns_none_for_non_cell_id(self, datagrid):
"""Test that _get_pos_from_element_id returns None for row IDs and None input.
Row and column IDs don't carry a (col, row) position. Returning None
signals that no cell-level position can be derived.
"""
assert datagrid._get_pos_from_element_id(f"trow_{datagrid._id}-5") is None
assert datagrid._get_pos_from_element_id(None) is None
element_id = element_id_template.format(id=datagrid._id) if element_id_template else None
assert datagrid._get_pos_from_element_id(element_id) == expected
# ------------------------------------------------------------------
# Static ID Conversions
@@ -384,6 +372,102 @@ class TestDataGridBehaviour:
assert col_def.type == ColumnType.Number
assert col_def.formula == "{age} + 1"
# ------------------------------------------------------------------
# Keyboard Navigation
# ------------------------------------------------------------------
# Column layout for datagrid_with_data (enable_edition=True):
# _state.columns : [name (idx 0), age (idx 1), active (idx 2)]
# _columns : [RowSelection_ (pos 0), name (pos 1), age (pos 2), active (pos 3)]
# Navigable positions: [1, 2, 3] — RowSelection_ (pos 0) always excluded.
def test_i_can_get_navigable_col_positions(self, datagrid_with_data):
"""RowSelection_ (pos 0) must be excluded; data columns (1, 2, 3) must be included."""
positions = datagrid_with_data._get_navigable_col_positions()
assert positions == [1, 2, 3]
def test_i_can_get_navigable_col_positions_with_hidden_column(self, datagrid_with_data):
"""Hidden columns must be excluded from navigable positions.
_state.columns[1] is 'age' → maps to _columns pos 2.
After hiding, navigable positions must be [1, 3].
"""
datagrid_with_data._state.columns[1].visible = False # hide 'age' (pos 2 in _columns)
datagrid_with_data._init_columns()
positions = datagrid_with_data._get_navigable_col_positions()
assert positions == [1, 3]
@pytest.mark.parametrize("start_pos, direction, expected_pos", [
# Normal navigation — right / left
((1, 0), "right", (2, 0)),
((2, 0), "right", (3, 0)),
((3, 0), "left", (2, 0)),
((2, 0), "left", (1, 0)),
# Normal navigation — down / up
((1, 0), "down", (1, 1)),
((1, 1), "down", (1, 2)),
((1, 2), "up", (1, 1)),
((1, 1), "up", (1, 0)),
# Boundaries — must stay in place
((3, 0), "right", (3, 0)),
((1, 0), "left", (1, 0)),
((1, 2), "down", (1, 2)),
((1, 0), "up", (1, 0)),
])
def test_i_can_navigate(self, datagrid_with_data, start_pos, direction, expected_pos):
"""Navigation moves to the expected position or stays at boundary."""
result = datagrid_with_data._navigate(start_pos, direction)
assert result == expected_pos
def test_i_can_navigate_right_skipping_invisible_column(self, datagrid_with_data):
"""→ from col 1 must skip hidden col 2 (age) and land on col 3 (active)."""
datagrid_with_data._state.columns[1].visible = False # hide 'age' → pos 2
datagrid_with_data._init_columns()
result = datagrid_with_data._navigate((1, 0), "right")
assert result == (3, 0)
def test_i_can_navigate_left_skipping_invisible_column(self, datagrid_with_data):
"""← from col 3 must skip hidden col 2 (age) and land on col 1 (name)."""
datagrid_with_data._state.columns[1].visible = False # hide 'age' → pos 2
datagrid_with_data._init_columns()
result = datagrid_with_data._navigate((3, 0), "left")
assert result == (1, 0)
def test_i_can_navigate_down_skipping_filtered_row(self, datagrid_with_data):
"""↓ from row 0 must skip filtered-out row 1 (Bob) and land on row 2 (Charlie).
Filter keeps age values "25" and "35" only → row 1 (age=30) is excluded.
Visible row indices become [0, 2].
"""
datagrid_with_data._state.filtered["age"] = ["25", "35"]
result = datagrid_with_data._navigate((1, 0), "down")
assert result == (1, 2)
@pytest.mark.parametrize("combination, start_pos, expected_pos", [
("arrowright", (1, 0), (2, 0)),
("arrowleft", (2, 0), (1, 0)),
("arrowdown", (1, 0), (1, 1)),
("arrowup", (1, 1), (1, 0)),
])
def test_i_can_navigate_with_arrow_keys(self, datagrid_with_data, combination, start_pos, expected_pos):
"""Arrow key presses update selection.selected to the expected position."""
datagrid_with_data._state.selection.selected = start_pos
datagrid_with_data.on_key_pressed(combination=combination, has_focus=True, is_inside=True)
assert datagrid_with_data._state.selection.selected == expected_pos
def test_i_can_navigate_from_last_selected_when_no_selection(self, datagrid_with_data):
"""When selected is None, navigation starts from last_selected."""
datagrid_with_data._state.selection.selected = None
datagrid_with_data._state.selection.last_selected = (1, 1)
datagrid_with_data.on_key_pressed(combination="arrowright", has_focus=True, is_inside=True)
assert datagrid_with_data._state.selection.selected == (2, 1)
def test_i_can_navigate_from_origin_when_no_selection_and_no_last_selected(self, datagrid_with_data):
"""When both selected and last_selected are None, navigation starts from (0, 0)."""
datagrid_with_data._state.selection.selected = None
datagrid_with_data._state.selection.last_selected = None
datagrid_with_data.on_key_pressed(combination="arrowright", has_focus=True, is_inside=True)
assert datagrid_with_data._state.selection.selected == (1, 0)
class TestDataGridRender:
@@ -488,60 +572,28 @@ class TestDataGridRender:
)
assert matches(html, expected)
def test_i_can_render_extra_selected_row(self, datagrid):
"""Test that a row extra-selection entry renders as a Div with selection_type='row'.
@pytest.mark.parametrize("sel_type, element_id_template", [
("row", "trow_{id}-3"),
("column", "tcol_{id}-2"),
("range", (0, 0, 2, 2)),
])
def test_i_can_render_extra_selected_entry(self, datagrid, sel_type, element_id_template):
"""Test that each extra-selection type renders as a child Div with the correct attributes.
Why these elements matter:
- selection_type='row': JS applies the row-stripe highlight to the entire row
- element_id: the DOM ID of the row element that JS will highlight
- selection_type: tells JS which highlight strategy to apply (row stripe,
column stripe, or range rectangle)
- element_id: the DOM target JS will highlight; strings are used as-is,
tuples (range bounds) are stringified so JS can parse the coordinates
"""
dg = datagrid
row_element_id = f"trow_{dg._id}-3"
dg._state.selection.extra_selected.append(("row", row_element_id))
element_id = element_id_template.format(id=dg._id) if isinstance(element_id_template, str) else element_id_template
dg._state.selection.extra_selected.append((sel_type, element_id))
html = dg.mk_selection_manager()
expected = Div(
Div(selection_type="row", element_id=row_element_id),
id=f"tsm_{dg._id}",
)
assert matches(html, expected)
def test_i_can_render_extra_selected_column(self, datagrid):
"""Test that a column extra-selection entry renders as a Div with selection_type='column'.
Why these elements matter:
- selection_type='column': JS applies the column-stripe highlight to the entire column
- element_id: the DOM ID of the column header element that JS will highlight
"""
dg = datagrid
col_element_id = f"tcol_{dg._id}-2"
dg._state.selection.extra_selected.append(("column", col_element_id))
html = dg.mk_selection_manager()
expected = Div(
Div(selection_type="column", element_id=col_element_id),
id=f"tsm_{dg._id}",
)
assert matches(html, expected)
def test_i_can_render_extra_selected_range(self, datagrid):
"""Test that a range extra-selection entry renders with the tuple stringified as element_id.
Why these elements matter:
- selection_type='range': JS draws a rectangular highlight over the cell region
- element_id=str(tuple): the range bounds (min_col, min_row, max_col, max_row)
are passed as a string; JS parses this to locate all cells in the rectangle
"""
dg = datagrid
range_bounds = (0, 0, 2, 2)
dg._state.selection.extra_selected.append(("range", range_bounds))
html = dg.mk_selection_manager()
expected = Div(
Div(selection_type="range", element_id=f"{range_bounds}"),
Div(selection_type=sel_type, element_id=f"{element_id}"),
id=f"tsm_{dg._id}",
)
assert matches(html, expected)
@@ -612,60 +664,34 @@ class TestDataGridRender:
col_headers = find(html, Div(cls=Contains("dt2-cell", "dt2-resizable")))
assert len(col_headers) == 3, "Should have one resizable header cell per visible data column"
def test_i_can_render_row_selection_header_in_edition_mode(self, datagrid_with_data):
"""Test that a RowSelection header cell is rendered when edition mode is enabled.
@pytest.mark.parametrize("css_cls, edition_enabled, expected_count", [
("dt2-row-selection", True, 1),
("dt2-row-selection", False, 0),
("dt2-add-column", True, 1),
("dt2-add-column", False, 0),
])
def test_i_can_render_header_edition_elements_visibility(
self, datagrid_with_data, css_cls, edition_enabled, expected_count):
"""Test that edition-specific header elements are present only when edition is enabled.
Why these elements matter:
- dt2-row-selection: the selection checkbox column is only meaningful in edition
mode where rows can be individually selected for bulk operations; JS uses this
cell to anchor the row-selection toggle handler
- exactly 1 cell: a second dt2-row-selection would double the checkbox column
- dt2-row-selection: the checkbox column is only meaningful in edition mode;
rendering it in read-only mode would create an orphan misaligned column
- dt2-add-column: the '+' icon exposes mutation UI; it must be hidden in
read-only grids to prevent users from adding columns unintentionally
- expected_count 1 vs 0: exactly one element when enabled, none when disabled,
prevents both missing controls and duplicated ones
"""
dg = datagrid_with_data
dg._settings.enable_edition = edition_enabled
dg._init_columns()
html = dg.mk_headers()
row_sel_cells = find(html, Div(cls=Contains("dt2-row-selection")))
assert len(row_sel_cells) == 1, "Edition mode must render exactly one row-selection header cell"
def test_i_cannot_render_row_selection_header_without_edition_mode(self, datagrid_no_edition):
"""Test that no RowSelection header cell is rendered when edition mode is disabled.
Why this matters:
- Without edition, there is no row selection column in _columns; rendering one
would create an orphan cell misaligned with the body rows
"""
dg = datagrid_no_edition
html = dg.mk_headers()
row_sel_cells = find(html, Div(cls=Contains("dt2-row-selection")))
assert len(row_sel_cells) == 0, "Without edition mode, no row-selection header cell should be rendered"
def test_i_can_render_add_column_button_in_edition_mode(self, datagrid_with_data):
"""Test that the add-column button is appended to the header in edition mode.
Why this element matters:
- dt2-add-column: the '+' icon at the end of the header lets users add new
columns interactively; it must be present in edition mode and absent otherwise
to avoid exposing mutation UI in read-only grids
"""
dg = datagrid_with_data
html = dg.mk_headers()
add_col_cells = find(html, Div(cls=Contains("dt2-add-column")))
assert len(add_col_cells) == 1, "Edition mode must render exactly one add-column button"
def test_i_cannot_render_add_column_button_without_edition_mode(self, datagrid_no_edition):
"""Test that no add-column button is rendered when edition mode is disabled.
Why this matters:
- Read-only grids must not expose mutation controls; the absence of dt2-add-column
guarantees that the JS handler for toggling the column editor is never reachable
"""
dg = datagrid_no_edition
html = dg.mk_headers()
add_col_cells = find(html, Div(cls=Contains("dt2-add-column")))
assert len(add_col_cells) == 0, "Without edition mode, no add-column button should be rendered"
elements = find(html, Div(cls=Contains(css_cls)))
assert len(elements) == expected_count, (
f"{'Edition' if edition_enabled else 'Read-only'} mode must render "
f"exactly {expected_count} '{css_cls}' element(s)"
)
def test_i_can_render_headers_in_column_order(self, datagrid_with_data):
"""Test that resizable header cells appear in the same order as self._columns.
@@ -787,6 +813,209 @@ class TestDataGridRender:
))
assert len(handles) == 3, "Each data column must have exactly one resize handle with correct command IDs"
# ------------------------------------------------------------------
# Table structure
# ------------------------------------------------------------------
def test_i_can_render_table_wrapper(self, datagrid_with_data):
"""Test that mk_table_wrapper renders with correct ID, class and 3 main sections.
Why these elements matter:
- id=tw_{id}: used by JS to position custom scrollbars over the table
- cls Contains 'dt2-table-wrapper': CSS hook for relative positioning that lets
the scrollbars overlay use absolute coordinates over the table
- tsm_{id}: selection manager lives inside the wrapper so it survives partial
re-renders that target the wrapper
- t_{id}: the table with header, body and footer
- dt2-scrollbars: custom scrollbar overlay (structure tested separately)
"""
dg = datagrid_with_data
html = dg.mk_table_wrapper()
expected = Div(
Div(id=f"tsm_{dg._id}"), # selection manager
Div(id=f"t_{dg._id}"), # table
Div(cls=Contains("dt2-scrollbars")), # scrollbars overlay
id=f"tw_{dg._id}",
cls=Contains("dt2-table-wrapper"),
)
assert matches(html, expected)
def test_i_can_render_table(self, datagrid_with_data):
"""Test that mk_table renders with correct ID, class and 3 container sections.
Why these elements matter:
- id=t_{id}: targeted by on_column_changed and render_partial('table') swaps
- cls Contains 'dt2-table': CSS grid container that aligns header, body and
footer columns
- dt2-header-container: wraps the header row with no-scroll behaviour
- tb_{id}: body wrapper, targeted by get_page for lazy-load row appends and by
render_partial('body') for full body swaps on filter/sort
- dt2-footer-container: wraps the aggregation footer with no-scroll behaviour
"""
dg = datagrid_with_data
html = dg.mk_table()
expected = Div(
Div(cls=Contains("dt2-header-container")), # header container
Div(id=f"tb_{dg._id}"), # body wrapper
Div(cls=Contains("dt2-footer-container")), # footer container
id=f"t_{dg._id}",
cls=Contains("dt2-table"),
)
assert matches(html, expected)
def test_i_can_render_table_has_scrollbars(self, datagrid_with_data):
"""Test that the scrollbars overlay contains both vertical and horizontal tracks.
Why these elements matter:
- dt2-scrollbars-vertical-wrapper / dt2-scrollbars-horizontal-wrapper: JS resizes
these wrappers to match the live table dimensions on each render
- dt2-scrollbars-vertical / dt2-scrollbars-horizontal: the visible scrollbar
thumbs that JS moves on scroll; missing either disables that scroll axis
"""
dg = datagrid_with_data
html = dg.mk_table_wrapper()
# Step 1: Find and validate the vertical scrollbar wrapper
vertical = find_one(html, Div(cls=Contains("dt2-scrollbars-vertical-wrapper")))
assert matches(vertical, Div(
Div(cls=Contains("dt2-scrollbars-vertical")),
cls=Contains("dt2-scrollbars-vertical-wrapper"),
))
# Step 2: Find and validate the horizontal scrollbar wrapper
horizontal = find_one(html, Div(cls=Contains("dt2-scrollbars-horizontal-wrapper")))
assert matches(horizontal, Div(
Div(cls=Contains("dt2-scrollbars-horizontal")),
cls=Contains("dt2-scrollbars-horizontal-wrapper"),
))
# ------------------------------------------------------------------
# render_partial fragments
# ------------------------------------------------------------------
def test_i_can_render_partial_body(self, datagrid_with_data):
"""Test that render_partial('body') returns (selection_manager, body_wrapper).
Why these elements matter:
- 2 elements: both the body and the selection manager are sent back together
so the cell highlight is updated in the same response as the body swap
- tsm_{id}: refreshes the cell highlight after the body is replaced
- tb_{id}: the HTMX target for filter and sort re-renders
- hx-on::after-settle Contains 'initDataGrid': re-initialises JS scroll and
resize logic after the new body is inserted into the DOM
"""
dg = datagrid_with_data
result = dg.render_partial("body")
# Step 1: Verify tuple length
assert len(result) == 2, "render_partial('body') must return (selection_manager, body_wrapper)"
# Step 2: Verify selection manager
assert matches(result[0], Div(id=f"tsm_{dg._id}"))
# Step 3: Verify body wrapper ID, class and after-settle attribute
assert matches(result[1], Div(id=f"tb_{dg._id}", cls=Contains("dt2-body-container")))
assert "initDataGrid" in result[1].attrs.get("hx-on::after-settle", ""), (
"Body wrapper must carry hx-on::after-settle with initDataGrid to re-init JS after swap"
)
def test_i_can_render_partial_table(self, datagrid_with_data):
"""Test that render_partial('table') returns (selection_manager, table).
Why these elements matter:
- 2 elements: body and selection manager are sent back together so the cell
highlight is updated in the same response as the table swap
- t_{id}: full table swap used by on_column_changed when columns are added,
hidden, or reordered; an incorrect ID would leave the old table in the DOM
- hx-on::after-settle Contains 'initDataGrid': re-initialises column resize
and drag-and-drop after the new table structure is inserted
"""
dg = datagrid_with_data
result = dg.render_partial("table")
# Step 1: Verify tuple length
assert len(result) == 2, "render_partial('table') must return (selection_manager, table)"
# Step 2: Verify selection manager
assert matches(result[0], Div(id=f"tsm_{dg._id}"))
# Step 3: Verify table ID, class and after-settle attribute
assert matches(result[1], Div(id=f"t_{dg._id}", cls=Contains("dt2-table")))
assert "initDataGrid" in result[1].attrs.get("hx-on::after-settle", ""), (
"Table must carry hx-on::after-settle with initDataGrid to re-init JS after column swap"
)
def test_i_can_render_partial_header(self, datagrid_with_data):
"""Test that render_partial('header') returns a single header element with setColumnWidth.
Why these elements matter:
- not a tuple: header swaps are triggered by reset_column_width which uses a
direct HTMX target (#th_{id}); returning a tuple would break the swap
- th_{id}: the HTMX target for the header swap after auto-size
- hx-on::after-settle Contains 'setColumnWidth': applies the new pixel width
to all body cells via JS after the header is swapped in
- col_id in after-settle: JS needs the column ID to target the correct cells
"""
dg = datagrid_with_data
col_id = dg._state.columns[0].col_id
result = dg.render_partial("header", col_id=col_id, optimal_width=200)
# Step 1: Verify it is a single element, not a tuple
assert not isinstance(result, tuple), "render_partial('header') must return a single element"
# Step 2: Verify header ID and class
assert matches(result, Div(id=f"th_{dg._id}", cls=Contains("dt2-header")))
# Step 3: Verify after-settle contains setColumnWidth and the column ID
after_settle = result.attrs.get("hx-on::after-settle", "")
assert "setColumnWidth" in after_settle, (
"Header must carry hx-on::after-settle with setColumnWidth to resize body cells"
)
assert col_id in after_settle, (
"hx-on::after-settle must include the column ID so JS targets the correct column"
)
def test_i_can_render_partial_cell_by_pos(self, datagrid_with_data):
"""Test that render_partial('cell', pos=...) returns (selection_manager, cell).
Why these elements matter:
- 2 elements: cell content and selection manager are sent back together so
the focus highlight is updated in the same response as the cell swap
- tsm_{id}: refreshes the focus highlight after the cell is replaced
- tcell_{id}-{col}-{row}: the HTMX swap target for individual cell updates
(edition entry/exit); an incorrect ID leaves the old cell content in the DOM
"""
dg = datagrid_with_data
name_col = next(c for c in dg._columns if c.title == "name")
col_pos = dg._columns.index(name_col)
result = dg.render_partial("cell", pos=(col_pos, 0))
# Step 1: Verify tuple length
assert len(result) == 2, "render_partial('cell', pos=...) must return (selection_manager, cell)"
# Step 2: Verify selection manager
assert matches(result[0], Div(id=f"tsm_{dg._id}"))
# Step 3: Verify cell ID and class
assert matches(result[1], Div(
id=f"tcell_{dg._id}-{col_pos}-0",
cls=Contains("dt2-cell"),
))
def test_i_can_render_partial_cell_with_no_position(self, datagrid_with_data):
"""Test that render_partial() with no position returns only (selection_manager,).
Why this matters:
- 1 element only: when no valid cell position can be resolved, only the
selection manager is returned to refresh the highlight state
- no cell element: no position means no cell to update in the DOM
"""
dg = datagrid_with_data
result = dg.render_partial()
assert len(result) == 1, "render_partial() with no position must return only (selection_manager,)"
assert matches(result[0], Div(id=f"tsm_{dg._id}"))
# ------------------------------------------------------------------
# Body
# ------------------------------------------------------------------