Compare commits
4 Commits
0c9c8bc7fa
...
WorkingOnD
| Author | SHA1 | Date | |
|---|---|---|---|
| 853bc4abae | |||
| 2fcc225414 | |||
| ef9f269a49 | |||
| 0951680466 |
@@ -205,7 +205,7 @@ def render(self):
|
||||
return Div(
|
||||
self._mk_content(),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.on_click()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.handle_on_click()),
|
||||
id=self._id
|
||||
)
|
||||
```
|
||||
|
||||
@@ -209,13 +209,14 @@ For interactive controls, compose `Keyboard` and `Mouse`:
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.Mouse import Mouse
|
||||
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
self._mk_content(),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.on_click()),
|
||||
id=self._id
|
||||
)
|
||||
return Div(
|
||||
self._mk_content(),
|
||||
Keyboard(self, _id="-keyboard").add("esc", self.commands.close()),
|
||||
Mouse(self, _id="-mouse").add("click", self.commands.handle_on_click()),
|
||||
id=self._id
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
118
docs/Datagrid Tests.md
Normal file
118
docs/Datagrid Tests.md
Normal 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** |
|
||||
@@ -14,6 +14,7 @@
|
||||
pendingMatches: [], // Array of matches waiting for timeout
|
||||
sequenceTimeout: 500, // 500ms timeout for sequences
|
||||
clickHandler: null,
|
||||
dblclickHandler: null, // Handler reference for dblclick
|
||||
contextmenuHandler: null,
|
||||
mousedownState: null, // Active drag state (only after movement detected)
|
||||
suppressNextClick: false, // Prevents click from firing after mousedown>mouseup
|
||||
@@ -35,7 +36,9 @@
|
||||
|
||||
// Handle aliases
|
||||
const aliasMap = {
|
||||
'rclick': 'right_click'
|
||||
'rclick': 'right_click',
|
||||
'double_click': 'dblclick',
|
||||
'dclick': 'dblclick'
|
||||
};
|
||||
|
||||
return aliasMap[normalized] || normalized;
|
||||
@@ -563,6 +566,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dblclick events (triggers for all registered elements).
|
||||
* Uses a fresh single-step history so it never conflicts with click sequences.
|
||||
* @param {MouseEvent} event - The dblclick event
|
||||
*/
|
||||
function handleDblClick(event) {
|
||||
const snapshot = createSnapshot(event, 'dblclick');
|
||||
const dblclickHistory = [snapshot];
|
||||
|
||||
const currentMatches = [];
|
||||
|
||||
for (const [elementId, data] of MouseRegistry.elements) {
|
||||
const element = document.getElementById(elementId);
|
||||
if (!element) continue;
|
||||
|
||||
const isInside = element.contains(event.target);
|
||||
const currentNode = traverseTree(data.tree, dblclickHistory);
|
||||
if (!currentNode || !currentNode.config) continue;
|
||||
|
||||
currentMatches.push({
|
||||
elementId: elementId,
|
||||
config: currentNode.config,
|
||||
combinationStr: currentNode.combinationStr,
|
||||
isInside: isInside
|
||||
});
|
||||
}
|
||||
|
||||
const anyMatchInside = currentMatches.some(m => m.isInside);
|
||||
if (anyMatchInside && !isInInputContext()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
for (const match of currentMatches) {
|
||||
triggerHtmxAction(match.elementId, match.config, match.combinationStr, match.isInside, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up mousedown state and safety timeout
|
||||
*/
|
||||
@@ -989,11 +1029,13 @@
|
||||
if (!MouseRegistry.listenerAttached) {
|
||||
// Store handler references for proper removal
|
||||
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
|
||||
MouseRegistry.dblclickHandler = (e) => handleDblClick(e);
|
||||
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
|
||||
MouseRegistry.mousedownHandler = (e) => handleMouseDown(e);
|
||||
MouseRegistry.mouseupHandler = (e) => handleMouseUp(e);
|
||||
|
||||
document.addEventListener('click', MouseRegistry.clickHandler);
|
||||
document.addEventListener('dblclick', MouseRegistry.dblclickHandler);
|
||||
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
document.addEventListener('mousedown', MouseRegistry.mousedownHandler);
|
||||
document.addEventListener('mouseup', MouseRegistry.mouseupHandler);
|
||||
@@ -1007,6 +1049,7 @@
|
||||
function detachGlobalListener() {
|
||||
if (MouseRegistry.listenerAttached) {
|
||||
document.removeEventListener('click', MouseRegistry.clickHandler);
|
||||
document.removeEventListener('dblclick', MouseRegistry.dblclickHandler);
|
||||
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
|
||||
document.removeEventListener('mousedown', MouseRegistry.mousedownHandler);
|
||||
document.removeEventListener('mouseup', MouseRegistry.mouseupHandler);
|
||||
@@ -1014,6 +1057,7 @@
|
||||
|
||||
// Clean up handler references
|
||||
MouseRegistry.clickHandler = null;
|
||||
MouseRegistry.dblclickHandler = null;
|
||||
MouseRegistry.contextmenuHandler = null;
|
||||
MouseRegistry.mousedownHandler = null;
|
||||
MouseRegistry.mouseupHandler = null;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,7 +3,7 @@ function initDataGrid(gridId) {
|
||||
initDataGridMouseOver(gridId);
|
||||
makeDatagridColumnsResizable(gridId);
|
||||
makeDatagridColumnsMovable(gridId);
|
||||
updateDatagridSelection(gridId)
|
||||
updateDatagridSelection(gridId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -673,9 +673,9 @@ function updateDatagridSelection(datagridId) {
|
||||
|
||||
// Clear browser text selection to prevent stale ranges from reappearing
|
||||
// But skip if an input/textarea/contenteditable has focus (would clear text cursor)
|
||||
if (!document.activeElement?.closest('input, textarea, [contenteditable]')) {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
// if (!document.activeElement?.closest('input, textarea, [contenteditable]')) {
|
||||
// window.getSelection()?.removeAllRanges();
|
||||
// }
|
||||
|
||||
// OPTIMIZATION: scope to table instead of scanning the entire document
|
||||
const table = document.getElementById(`t_${datagridId}`);
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
|
||||
@@ -18,9 +17,7 @@ from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.Mouse import Mouse
|
||||
from myfasthtml.controls.Panel import Panel, PanelConf
|
||||
from myfasthtml.controls.Query import Query, QUERY_FILTER
|
||||
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowUiState, \
|
||||
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState, DataGridColumnUiState, \
|
||||
DataGridRowSelectionColumnState
|
||||
from myfasthtml.controls.datagrid_objects import *
|
||||
from myfasthtml.controls.helpers import mk
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import ColumnType, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
|
||||
@@ -156,7 +153,7 @@ class Commands(BaseCommands):
|
||||
return Command("OnClick",
|
||||
"Click on the table",
|
||||
self._owner,
|
||||
self._owner.on_click
|
||||
self._owner.handle_on_click
|
||||
).htmx(target=f"#tsm_{self._id}")
|
||||
|
||||
def on_key_pressed(self):
|
||||
@@ -212,9 +209,31 @@ class Commands(BaseCommands):
|
||||
self._owner,
|
||||
self._owner.on_column_changed
|
||||
)
|
||||
|
||||
def start_edition(self):
|
||||
return Command("StartEdition",
|
||||
"Enter cell edit mode",
|
||||
self._owner,
|
||||
self._owner.handle_start_edition
|
||||
).htmx(target=f"#tsm_{self._id}")
|
||||
|
||||
def save_edition(self):
|
||||
return Command("SaveEdition",
|
||||
"Save cell edition",
|
||||
self._owner,
|
||||
self._owner.handle_save_edition
|
||||
).htmx(target=f"#tsm_{self._id}",
|
||||
trigger="blur, keydown[key=='Enter']")
|
||||
|
||||
|
||||
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__")
|
||||
@@ -293,10 +312,16 @@ class DataGrid(MultipleInstance):
|
||||
"click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
"ctrl+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
"shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"},
|
||||
"dblclick": {"command": self.commands.start_edition(), "hx_vals": "js:getCellId()"},
|
||||
}
|
||||
|
||||
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.")
|
||||
@@ -373,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
|
||||
@@ -451,6 +507,26 @@ class DataGrid(MultipleInstance):
|
||||
if self._settings.enable_edition:
|
||||
self._columns.insert(0, DataGridRowSelectionColumnState())
|
||||
|
||||
def _enter_edition(self, pos):
|
||||
col_pos, row_index = pos
|
||||
col_def = self._columns[col_pos]
|
||||
if col_def.type in (ColumnType.RowSelection_, ColumnType.RowIndex, ColumnType.Formula):
|
||||
return self.render_partial()
|
||||
self._state.edition.under_edition = pos
|
||||
self._state.save()
|
||||
return self.render_partial("cell", pos=pos)
|
||||
|
||||
def _convert_edition_value(self, value_str, col_type):
|
||||
if col_type == ColumnType.Number:
|
||||
try:
|
||||
return float(value_str) if '.' in value_str else int(value_str)
|
||||
except (ValueError, TypeError):
|
||||
return value_str
|
||||
elif col_type == ColumnType.Bool:
|
||||
return value_str.lower() in ('true', '1', 'yes')
|
||||
else:
|
||||
return value_str
|
||||
|
||||
def add_new_column(self, col_def: DataGridColumnState) -> None:
|
||||
"""Add a new column, delegating data mutation to DataService.
|
||||
|
||||
@@ -571,14 +647,20 @@ class DataGrid(MultipleInstance):
|
||||
self._state.filtered[FILTER_INPUT_CID] = self._datagrid_filter.get_query()
|
||||
return self.render_partial("body")
|
||||
|
||||
def on_click(self, combination, is_inside, cell_id):
|
||||
def handle_on_click(self, combination, is_inside, cell_id):
|
||||
logger.debug(f"on_click table={self.get_table_name()} {combination=} {is_inside=} {cell_id=}")
|
||||
if is_inside and cell_id:
|
||||
self._state.selection.extra_selected.clear()
|
||||
|
||||
if cell_id.startswith("tcell_"):
|
||||
pos = self._get_pos_from_element_id(cell_id)
|
||||
self._update_current_position(pos)
|
||||
pos = self._get_pos_from_element_id(cell_id)
|
||||
|
||||
if (self._settings.enable_edition and
|
||||
pos is not None and
|
||||
pos == self._state.selection.selected and
|
||||
self._state.edition.under_edition is None):
|
||||
return self._enter_edition(pos)
|
||||
|
||||
self._update_current_position(pos)
|
||||
|
||||
return self.render_partial()
|
||||
|
||||
@@ -605,6 +687,18 @@ class DataGrid(MultipleInstance):
|
||||
if combination == "esc":
|
||||
self._update_current_position(None)
|
||||
self._state.selection.extra_selected.clear()
|
||||
elif (combination == "enter" and
|
||||
self._settings.enable_edition and
|
||||
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()
|
||||
|
||||
@@ -674,6 +768,30 @@ class DataGrid(MultipleInstance):
|
||||
self._panel.set_title(side="right", title="Formatting")
|
||||
self._panel.set_right(self._formatting_editor)
|
||||
|
||||
def handle_start_edition(self, cell_id):
|
||||
logger.debug(f"handle_start_edition: {cell_id=}")
|
||||
if not self._settings.enable_edition:
|
||||
return self.render_partial()
|
||||
if self._state.edition.under_edition is not None:
|
||||
return self.render_partial()
|
||||
pos = self._get_pos_from_element_id(cell_id)
|
||||
if pos is None:
|
||||
return self.render_partial()
|
||||
self._update_current_position(pos)
|
||||
return self._enter_edition(pos)
|
||||
|
||||
def handle_save_edition(self, value):
|
||||
logger.debug(f"handle_save_edition: {value=}")
|
||||
if self._state.edition.under_edition is None:
|
||||
return self.render_partial()
|
||||
col_pos, row_index = self._state.edition.under_edition
|
||||
col_def = self._columns[col_pos]
|
||||
typed_value = self._convert_edition_value(value, col_def.type)
|
||||
self._data_service.set_data(col_def.col_id, row_index, typed_value)
|
||||
self._state.edition.under_edition = None
|
||||
self._state.save()
|
||||
return self.render_partial("cell", pos=(col_pos, row_index))
|
||||
|
||||
def handle_set_column_width(self, col_id: str, width: str):
|
||||
"""Update column width after resize. Called via Command from JS."""
|
||||
logger.debug(f"set_column_width: {col_id=} {width=}")
|
||||
@@ -724,6 +842,31 @@ class DataGrid(MultipleInstance):
|
||||
def get_data_service_id_from_data_grid_id(datagrid_id):
|
||||
return datagrid_id.replace(DataGrid.compute_prefix(), DataService.compute_prefix(), 1)
|
||||
|
||||
def _mk_edition_cell(self, col_pos, row_index, col_def: DataGridColumnState, is_last):
|
||||
col_array = self._fast_access.get(col_def.col_id)
|
||||
value = col_array[row_index] if col_array is not None and row_index < len(col_array) else None
|
||||
value_str = str(value) if not is_null(value) else ""
|
||||
|
||||
save_cmd = self.commands.save_edition()
|
||||
input_elem = mk.mk(
|
||||
Input(value=value_str, name="value", autofocus=True, cls="dt2-cell-input"),
|
||||
command=save_cmd
|
||||
)
|
||||
|
||||
return OptimizedDiv(
|
||||
input_elem,
|
||||
id=self._get_element_id_from_pos("cell", (col_pos, row_index)),
|
||||
cls=merge_classes("dt2-cell dt2-cell-edition", "dt2-last-cell" if is_last else None),
|
||||
style=f"width:{col_def.width}px;"
|
||||
)
|
||||
|
||||
def _mk_cell_oob(self, col_pos, row_index):
|
||||
col_def = self._columns[col_pos]
|
||||
filter_keyword = self._state.filtered.get(FILTER_INPUT_CID)
|
||||
filter_keyword_lower = filter_keyword.lower() if filter_keyword else None
|
||||
is_last = col_pos == len(self._columns) - 1
|
||||
return self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower, is_last)
|
||||
|
||||
def mk_headers(self):
|
||||
resize_cmd = self.commands.set_column_width()
|
||||
move_cmd = self.commands.move_column()
|
||||
@@ -794,7 +937,7 @@ class DataGrid(MultipleInstance):
|
||||
res.append(Span(value_str[:index], cls=f"{css_class}"))
|
||||
res.append(Span(value_str[index:index + len_keyword], cls="dt2-highlight-1"))
|
||||
if index + len_keyword < len(value_str):
|
||||
res.append(Span(value_str[index + len_keyword:], cls=f"{css_class}"))
|
||||
res.append(Span(value_str[index + len_keyword:]))
|
||||
|
||||
return Span(*res, cls=f"{css_class} truncate", style=style) if len(res) > 1 else res[0]
|
||||
|
||||
@@ -867,6 +1010,10 @@ class DataGrid(MultipleInstance):
|
||||
if col_def.type == ColumnType.RowSelection_:
|
||||
return OptimizedDiv(cls="dt2-row-selection")
|
||||
|
||||
if (self._settings.enable_edition and
|
||||
self._state.edition.under_edition == (col_pos, row_index)):
|
||||
return self._mk_edition_cell(col_pos, row_index, col_def, is_last)
|
||||
|
||||
col_array = self._fast_access.get(col_def.col_id)
|
||||
value = col_array[row_index] if col_array is not None and row_index < len(col_array) else None
|
||||
content = self.mk_body_cell_content(col_pos, row_index, col_def, filter_keyword_lower)
|
||||
@@ -876,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):
|
||||
@@ -1095,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):
|
||||
@@ -1105,7 +1254,7 @@ class DataGrid(MultipleInstance):
|
||||
:param kwargs: Additional parameters for specific fragments (col_id, optimal_width for header)
|
||||
:return:
|
||||
"""
|
||||
res = []
|
||||
res = [self.mk_selection_manager()]
|
||||
|
||||
extra_attr = {
|
||||
"hx-on::after-settle": f"initDataGrid('{self._id}');",
|
||||
@@ -1133,7 +1282,21 @@ class DataGrid(MultipleInstance):
|
||||
header.attrs.update(header_extra_attr)
|
||||
return header
|
||||
|
||||
res.append(self.mk_selection_manager())
|
||||
else:
|
||||
col_pos, row_index = None, None
|
||||
|
||||
if (cell_id := kwargs.get("cell_id")) is not None:
|
||||
col_pos, row_index = self._get_pos_from_element_id(cell_id)
|
||||
elif (pos := kwargs.get("pos")) is not None:
|
||||
col_pos, row_index = pos
|
||||
|
||||
if col_pos is not None and row_index is not None:
|
||||
col_def = self._columns[col_pos]
|
||||
filter_keyword = self._state.filtered.get(FILTER_INPUT_CID)
|
||||
filter_keyword_lower = filter_keyword.lower() if filter_keyword else None
|
||||
is_last_col = col_pos == len(self._columns) - 1
|
||||
cell = self.mk_body_cell(col_pos, row_index, col_def, filter_keyword_lower, is_last_col)
|
||||
res.append(cell)
|
||||
|
||||
return tuple(res)
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ class Mouse(MultipleInstance):
|
||||
|
||||
VALID_ACTIONS = {
|
||||
'click', 'right_click', 'rclick',
|
||||
'mousedown>mouseup', 'rmousedown>mouseup'
|
||||
'mousedown>mouseup', 'rmousedown>mouseup',
|
||||
'dblclick', 'double_click', 'dclick'
|
||||
}
|
||||
VALID_MODIFIERS = {'ctrl', 'shift', 'alt'}
|
||||
def __init__(self, parent, _id=None, combinations=None):
|
||||
|
||||
@@ -174,7 +174,7 @@ class Command:
|
||||
# Set the hx-swap-oob attribute on all elements returned by the callback
|
||||
if self._htmx_extra[AUTO_SWAP_OOB]:
|
||||
for index, r in enumerate(all_ret[1:]):
|
||||
if hasattr(r, "__ft__"):
|
||||
if not hasattr(r, 'attrs') and hasattr(r, "__ft__"):
|
||||
r = r.__ft__()
|
||||
all_ret[index + 1] = r
|
||||
if (hasattr(r, 'attrs')
|
||||
|
||||
@@ -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
|
||||
@@ -318,7 +306,7 @@ class TestDataGridBehaviour:
|
||||
dg = datagrid
|
||||
dg._state.selection.selected = (1, 2)
|
||||
|
||||
dg.on_click("click", is_inside=False, cell_id=f"tcell_{dg._id}-1-2")
|
||||
dg.handle_on_click("click", is_inside=False, cell_id=f"tcell_{dg._id}-1-2")
|
||||
|
||||
assert dg._state.selection.selected == (1, 2)
|
||||
|
||||
@@ -332,7 +320,7 @@ class TestDataGridBehaviour:
|
||||
dg = datagrid
|
||||
cell_id = f"tcell_{dg._id}-2-5"
|
||||
|
||||
dg.on_click("click", is_inside=True, cell_id=cell_id)
|
||||
dg.handle_on_click("click", is_inside=True, cell_id=cell_id)
|
||||
|
||||
assert dg._state.selection.selected == (2, 5)
|
||||
|
||||
@@ -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))
|
||||
|
||||
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'.
|
||||
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))
|
||||
|
||||
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,10 +813,213 @@ 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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_i_can_render_body_wrapper(self, datagrid_with_data):
|
||||
"""Test that the body wrapper renders with the correct ID and CSS class.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user