6 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
ef9f269a49 I can edit a cell 2026-03-16 21:16:21 +01:00
0951680466 Fixed minor issues.
* highlighted cells not correctly rendered
* text selection not correctly working
2026-03-15 18:45:55 +01:00
0c9c8bc7fa Fixed FormattingRules not being applied 2026-03-15 16:50:21 +01:00
feb9da50b2 Implemented enable/disable for keyboard support 2026-03-15 08:33:39 +01:00
22 changed files with 1538 additions and 618 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

@@ -100,6 +100,57 @@ add_keyboard_support('elem2', '{"C D": "/url2"}');
The timeout is tied to the **sequence being typed**, not to individual elements.
### Enabling and Disabling Combinations
Each combination can be enabled or disabled independently. A disabled combination is registered and tracked, but its action is never triggered.
| State | Behavior |
|-------|----------|
| `enabled=True` (default) | Combination triggers normally |
| `enabled=False` | Combination is silently ignored when pressed |
**Setting the initial state at registration:**
```python
# Enabled by default
keyboard.add("ctrl+s", self.commands.save())
# Disabled at startup
keyboard.add("ctrl+d", self.commands.delete(), enabled=False)
```
**Toggling dynamically at runtime:**
Use `mk_enable()` and `mk_disable()` to change the state from a server response. Both methods return an out-of-band HTMX element (`hx-swap-oob`) that updates the DOM without a full page reload.
```python
# Enable a combination (e.g., once an item is selected)
def handle_select(self):
item = ...
return item.render(), self.keyboard.mk_enable("ctrl+d")
# Disable a combination (e.g., when nothing is selected)
def handle_deselect(self):
return self.keyboard.mk_disable("ctrl+d")
```
The enabled state is stored in a hidden control `<div>` rendered alongside the keyboard script. The JavaScript reads this state before triggering any action.
**State at a glance:**
```
┌─────────────────────────────────────┐
│ Keyboard control div (hidden) │
│ ┌──────────────────────────────┐ │
│ │ div [data-combination="esc"] │ │
│ │ [data-enabled="true"] │ │
│ ├──────────────────────────────┤ │
│ │ div [data-combination="ctrl+d"] │ │
│ │ [data-enabled="false"] │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
```
### Smart Timeout Logic (Longest Match)
Keyboard shortcuts are **disabled** when typing in input fields:
@@ -364,16 +415,56 @@ remove_keyboard_support('modal');
## API Reference
### add_keyboard_support(elementId, combinationsJson)
### add_keyboard_support(elementId, controlDivId, combinationsJson)
Adds keyboard support to an element.
**Parameters**:
- `elementId` (string): ID of the HTML element
- `elementId` (string): ID of the HTML element to watch for key events
- `controlDivId` (string): ID of the keyboard control div rendered by `Keyboard.render()`, used to look up enabled/disabled state at runtime
- `combinationsJson` (string): JSON string of combinations with HTMX configs
**Returns**: void
### Python Component Methods
These methods are available on the `Keyboard` instance.
#### add(sequence, command, require_inside=True, enabled=True)
Registers a key combination.
| Parameter | Type | Description | Default |
|-----------|------|-------------|---------|
| `sequence` | `str` | Key combination string, e.g. `"ctrl+s"`, `"esc"`, `"a b"` | — |
| `command` | `Command` | Command to execute when the combination is triggered | — |
| `require_inside` | `bool` | If `True`, only triggers when focus is inside the element | `True` |
| `enabled` | `bool` | Whether the combination is active at render time | `True` |
**Returns**: `self` (chainable)
#### mk_enable(sequence)
Returns an out-of-band HTMX element that enables a combination at runtime.
| Parameter | Type | Description |
|-----------|------|-------------|
| `sequence` | `str` | Key combination to enable, must match exactly what was passed to `add()` |
**Returns**: `Div` with `hx-swap-oob="true"`
#### mk_disable(sequence)
Returns an out-of-band HTMX element that disables a combination at runtime.
| Parameter | Type | Description |
|-----------|------|-------------|
| `sequence` | `str` | Key combination to disable, must match exactly what was passed to `add()` |
**Returns**: `Div` with `hx-swap-oob="true"`
---
### remove_keyboard_support(elementId)
Removes keyboard support from an element.

View File

@@ -181,13 +181,13 @@ Users can rename nodes via the edit button:
```python
# Programmatically start rename
tree._start_rename("node-id")
tree.handle_start_rename("node-id")
# Save rename
tree._save_rename("node-id", "New Label")
tree.handle_save_rename("node-id", "New Label")
# Cancel rename
tree._cancel_rename()
tree.handle_cancel_rename()
```
### Deleting Nodes
@@ -201,7 +201,7 @@ Users can delete nodes via the delete button:
```python
# Programmatically delete node
tree._delete_node("node-id") # Raises ValueError if node has children
tree.handle_delete_node("node-id") # Raises ValueError if node has children
```
## Content System
@@ -449,18 +449,20 @@ tree = TreeView(parent=root_instance, _id="dynamic-tree")
root = TreeNode(id="root", label="Tasks", type="folder")
tree.add_node(root)
# Function to handle selection
def on_node_selected(node_id):
# Custom logic when node is selected
node = tree._state.items[node_id]
tree._select_node(node_id)
# Custom logic when node is selected
node = tree._state.items[node_id]
tree.handle_select_node(node_id)
# Update a detail panel elsewhere in the UI
return Div(
H3(f"Selected: {node.label}"),
P(f"Type: {node.type}"),
P(f"Children: {len(node.children)}")
)
# Update a detail panel elsewhere in the UI
return Div(
H3(f"Selected: {node.label}"),
P(f"Type: {node.type}"),
P(f"Children: {len(node.children)}")
)
# Override select command with custom handler
# (In practice, you'd extend the Commands class or use event callbacks)

View File

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

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

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

View File

@@ -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,15 +209,40 @@ 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__")
self._settings = DatagridSettings(self, save_state=save_state, name=name, namespace=namespace)
self._state = DatagridState(self, save_state=self._settings.save_state)
self._formatting_engine = FormattingEngine()
self._formatting_provider = DatagridMetadataProvider(self._parent)
self._formatting_engine = FormattingEngine(
rule_presets_provider=lambda: self._formatting_provider.rule_presets
)
self._columns = None
self.commands = Commands(self)
@@ -268,8 +290,7 @@ class DataGrid(MultipleInstance):
# self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed())
if self._settings.enable_formatting:
provider = DatagridMetadataProvider(self._parent)
completion_engine = FormattingCompletionEngine(provider, self.get_table_name())
completion_engine = FormattingCompletionEngine(self._formatting_provider, self.get_table_name())
editor_conf = DslEditorConf(engine_id=completion_engine.get_id())
dsl = FormattingDSL()
self._formatting_editor = DataGridFormattingEditor(self,
@@ -291,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": True},
"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.")
@@ -371,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
@@ -449,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.
@@ -569,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()
@@ -603,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()
@@ -672,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=}")
@@ -722,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()
@@ -792,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]
@@ -865,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)
@@ -874,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):
@@ -1093,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):
@@ -1103,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}');",
@@ -1131,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)

View File

@@ -211,7 +211,7 @@ class DataGridsManager(SingleInstance):
if parent_id not in self._tree.get_state().opened:
self._tree.get_state().opened.append(parent_id)
self._tree.get_state().selected = document.document_id
self._tree._start_rename(document.document_id)
self._tree.handle_start_rename(document.document_id)
return self._tree, self._tabs_manager.show_tab(tab_id)

View File

@@ -140,7 +140,7 @@ class HierarchicalCanvasGraph(MultipleInstance):
self._query.bind_command("CancelQuery", self.commands.apply_filter())
# Add Menu
self._menu = Menu(self, conf=MenuConf(["ResetView"]))
self._menu = Menu(self, conf=MenuConf(["ResetView"]), _id="-menu")
logger.debug(f"HierarchicalCanvasGraph created with id={self._id}, "
f"nodes={len(conf.nodes)}, edges={len(conf.edges)}")

View File

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

View File

@@ -9,7 +9,7 @@ import uuid
from dataclasses import dataclass, field
from typing import Optional
from fasthtml.components import Div, Input, Span
from fasthtml.components import Div, Input
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.IconsHelper import IconsHelper
@@ -115,7 +115,7 @@ class Commands(BaseCommands):
return Command("StartRename",
f"Start renaming {node_id}",
self._owner,
self._owner._start_rename,
self._owner.handle_start_rename,
kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-StartRename"
).htmx(target=f"#{self._owner.get_id()}")
@@ -125,7 +125,7 @@ class Commands(BaseCommands):
return Command("SaveRename",
f"Save rename for {node_id}",
self._owner,
self._owner._save_rename,
self._owner.handle_save_rename,
kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-SaveRename"
).htmx(target=f"#{self._owner.get_id()}")
@@ -135,7 +135,7 @@ class Commands(BaseCommands):
return Command("CancelRename",
"Cancel rename",
self._owner,
self._owner._cancel_rename,
self._owner.handle_cancel_rename,
key=f"{self._owner.get_safe_parent_key()}-CancelRename"
).htmx(target=f"#{self._owner.get_id()}")
@@ -144,7 +144,7 @@ class Commands(BaseCommands):
return Command("DeleteNode",
f"Delete node {node_id}",
self._owner,
self._owner._delete_node,
self._owner.handle_delete_node,
kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-DeleteNode"
).htmx(target=f"#{self._owner.get_id()}")
@@ -154,7 +154,7 @@ class Commands(BaseCommands):
return Command("SelectNode",
f"Select node {node_id}",
self._owner,
self._owner._select_node,
self._owner.handle_select_node,
kwargs={"node_id": node_id},
key=f"{self._owner.get_safe_parent_key()}-SelectNode"
).htmx(target=f"#{self._owner.get_id()}")
@@ -185,6 +185,10 @@ class TreeView(MultipleInstance):
self._state = TreeViewState(self)
self.conf = conf or TreeViewConf()
self.commands = Commands(self)
self._keyboard = Keyboard(self, {"esc":
{"command": self.commands.cancel_rename(),
"require_inside": False}},
_id="-keyboard"),
if items:
self._state.items = items
@@ -293,7 +297,7 @@ class TreeView(MultipleInstance):
return self._state.items[node_id].bag
except KeyError:
return None
def get_state(self) -> TreeViewState:
return self._state
@@ -346,7 +350,7 @@ class TreeView(MultipleInstance):
return self
def _start_rename(self, node_id: str):
def handle_start_rename(self, node_id: str):
"""Start renaming a node (sets editing state and selection)."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
@@ -355,7 +359,7 @@ class TreeView(MultipleInstance):
self._state.editing = node_id
return self
def _save_rename(self, node_id: str, node_label: str):
def handle_save_rename(self, node_id: str, node_label: str):
"""Save renamed node with new label."""
logger.debug(f"_save_rename {node_id=}, {node_label=}")
if node_id not in self._state.items:
@@ -365,13 +369,13 @@ class TreeView(MultipleInstance):
self._state.editing = None
return self
def _cancel_rename(self):
def handle_cancel_rename(self):
"""Cancel renaming operation."""
logger.debug("_cancel_rename")
self._state.editing = None
return self
def _delete_node(self, node_id: str):
def handle_delete_node(self, node_id: str):
"""Delete a node (only if it has no children)."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
@@ -397,7 +401,7 @@ class TreeView(MultipleInstance):
return self
def _select_node(self, node_id: str):
def handle_select_node(self, node_id: str):
"""Select a node."""
if node_id not in self._state.items:
raise ValueError(f"Node {node_id} does not exist")
@@ -511,7 +515,7 @@ class TreeView(MultipleInstance):
return Div(
*[self._render_node(node_id) for node_id in root_nodes],
Keyboard(self, {"esc": {"command": self.commands.cancel_rename(), "require_inside": False}}, _id="-keyboard"),
self._keyboard,
id=self._id,
cls="mf-treeview"
)

View File

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

View File

@@ -15,12 +15,12 @@ from myfasthtml.core.formatting.dataclasses import RulePreset
from myfasthtml.core.formatting.presets import (
DEFAULT_FORMATTER_PRESETS, DEFAULT_STYLE_PRESETS, DEFAULT_RULE_PRESETS,
)
from myfasthtml.core.instances import SingleInstance, InstancesManager
from myfasthtml.core.instances import UniqueInstance, InstancesManager
logger = logging.getLogger(__name__)
class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
class DatagridMetadataProvider(UniqueInstance, BaseMetadataProvider):
"""Concrete session-scoped metadata provider for DataGrid DSL engines.
Implements BaseMetadataProvider by delegating live data queries to
@@ -36,8 +36,7 @@ class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider):
all_tables_formats: Global format rules applied to all tables.
"""
def __init__(self, parent=None, session: Optional[dict] = None,
_id: Optional[str] = None):
def __init__(self, parent=None, session: Optional[dict] = None, _id: Optional[str] = None):
super().__init__(parent, session, _id)
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()

View File

@@ -31,7 +31,8 @@ class FormattingEngine:
style_presets: dict = None,
formatter_presets: dict = None,
rule_presets: dict = None,
lookup_resolver: Callable[[str, str, str], dict] = None
lookup_resolver: Callable[[str, str, str], dict] = None,
rule_presets_provider: Callable[[], dict] = None,
):
"""
Initialize the FormattingEngine.
@@ -41,11 +42,20 @@ class FormattingEngine:
formatter_presets: Custom formatter presets. If None, uses defaults.
rule_presets: Named rule presets (list of FormatRule dicts). If None, uses defaults.
lookup_resolver: Function for resolving enum datagrid sources.
rule_presets_provider: Callable returning the current rule_presets dict.
When provided, takes precedence over rule_presets on every apply_format call.
Use this to keep the engine in sync with a shared provider.
"""
self._condition_evaluator = ConditionEvaluator()
self._style_resolver = StyleResolver(style_presets)
self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver)
self._rule_presets = rule_presets if rule_presets is not None else DEFAULT_RULE_PRESETS
self._rule_presets_provider = rule_presets_provider
def _get_rule_presets(self) -> dict:
if self._rule_presets_provider is not None:
return self._rule_presets_provider()
return self._rule_presets
def apply_format(
self,
@@ -99,8 +109,8 @@ class FormattingEngine:
"""
Replace any FormatRule that references a rule preset with the preset's rules.
A rule is a rule preset reference when its formatter has a preset name
that exists in rule_presets (and not in formatter_presets).
A rule is a rule preset reference when its formatter or style has a preset name
that exists in rule_presets.
Args:
rules: Original list of FormatRule
@@ -108,22 +118,26 @@ class FormattingEngine:
Returns:
Expanded list with preset references replaced by their FormatRules
"""
rule_presets = self._get_rule_presets()
expanded = []
for rule in rules:
preset_name = self._get_rule_preset_name(rule)
preset_name = self._get_rule_preset_name(rule, rule_presets)
if preset_name:
expanded.extend(self._rule_presets[preset_name].rules)
expanded.extend(rule_presets[preset_name].rules)
else:
expanded.append(rule)
return expanded
def _get_rule_preset_name(self, rule: FormatRule) -> str | None:
"""Return the preset name if the rule's formatter references a rule preset, else None."""
if rule.formatter is None:
return None
preset = getattr(rule.formatter, "preset", None)
if preset and preset in self._rule_presets:
return preset
def _get_rule_preset_name(self, rule: FormatRule, rule_presets: dict) -> str | None:
"""Return the preset name if the rule references a rule preset via format() or style(), else None."""
if rule.formatter is not None:
preset = getattr(rule.formatter, "preset", None)
if preset and preset in rule_presets:
return preset
if rule.style is not None:
preset = getattr(rule.style, "preset", None)
if preset and preset in rule_presets:
return preset
return None
def _get_matching_rules(

View File

@@ -5,7 +5,7 @@ from typing import Optional, Literal
from myfasthtml.controls.helpers import Ids
from myfasthtml.core.commands import BoundCommand, Command
from myfasthtml.core.constants import NO_DEFAULT_VALUE
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal
from myfasthtml.core.utils import pascal_to_snake, get_class, snake_to_pascal, debug_session
VERBOSE_VERBOSE = False
@@ -36,7 +36,13 @@ class BaseInstance:
session = args[1] if len(args) > 1 and isinstance(args[1], dict) else kwargs.get("session", None)
_id = args[2] if len(args) > 2 and isinstance(args[2], str) else kwargs.get("_id", None)
if VERBOSE_VERBOSE:
logger.debug(f" parent={parent}, session={session}, _id={_id}")
logger.debug(f" parent={parent}, session={debug_session(session)}, _id={_id}")
# for UniqueInstance, the parent is always the ultimate root parent
if issubclass(cls, UniqueInstance):
parent = BaseInstance.get_ultimate_root_parent(parent)
if VERBOSE_VERBOSE:
logger.debug(f" UniqueInstance detected. parent is set to ultimate root {parent=}")
# Compute _id
_id = cls.compute_id(_id, parent)
@@ -163,7 +169,7 @@ class BaseInstance:
def compute_id(cls, _id: Optional[str], parent: Optional['BaseInstance']):
if _id is None:
prefix = cls.compute_prefix()
if issubclass(cls, SingleInstance):
if issubclass(cls, (SingleInstance, UniqueInstance)):
_id = prefix
else:
_id = f"{prefix}-{str(uuid.uuid4())}"
@@ -173,6 +179,17 @@ class BaseInstance:
return f"{parent.get_id()}{_id}"
return _id
@staticmethod
def get_ultimate_root_parent(instance):
if instance is None:
return None
parent = instance
while True:
if parent.get_parent() is None:
return parent
parent = parent.get_parent()
class SingleInstance(BaseInstance):
@@ -200,7 +217,7 @@ class UniqueInstance(BaseInstance):
_id: Optional[str] = None,
auto_register: bool = True,
on_init=None):
super().__init__(parent, session, _id, auto_register)
super().__init__(BaseInstance.get_ultimate_root_parent(parent), session, _id, auto_register)
if on_init is not None:
on_init()

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

View File

@@ -72,7 +72,7 @@ class TestDataGridsManagerBehaviour:
"""
# Create a folder and select it
folder_id = datagrids_manager._tree.ensure_path("MyFolder")
datagrids_manager._tree._select_node(folder_id)
datagrids_manager._tree.handle_select_node(folder_id)
result = datagrids_manager.handle_new_grid()
@@ -101,7 +101,7 @@ class TestDataGridsManagerBehaviour:
datagrids_manager._tree.add_node(leaf, parent_id=folder_id)
# Select the leaf
datagrids_manager._tree._select_node(leaf.id)
datagrids_manager._tree.handle_select_node(leaf.id)
result = datagrids_manager.handle_new_grid()

View File

@@ -145,7 +145,7 @@ class TestTreeviewBehaviour:
node = TreeNode(label="Node", type="folder")
tree_view.add_node(node)
tree_view._select_node(node.id)
tree_view.handle_select_node(node.id)
assert tree_view._state.selected == node.id
@@ -155,7 +155,7 @@ class TestTreeviewBehaviour:
node = TreeNode(label="Old Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
assert tree_view._state.editing == node.id
@@ -164,9 +164,9 @@ class TestTreeviewBehaviour:
tree_view = TreeView(root_instance)
node = TreeNode(label="Old Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
tree_view._save_rename(node.id, "New Name")
tree_view.handle_save_rename(node.id, "New Name")
assert tree_view._state.items[node.id].label == "New Name"
assert tree_view._state.editing is None
@@ -176,9 +176,9 @@ class TestTreeviewBehaviour:
tree_view = TreeView(root_instance)
node = TreeNode(label="Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
tree_view._cancel_rename()
tree_view.handle_cancel_rename()
assert tree_view._state.editing is None
assert tree_view._state.items[node.id].label == "Name"
@@ -193,7 +193,7 @@ class TestTreeviewBehaviour:
tree_view.add_node(child, parent_id=parent.id)
# Delete child (leaf node)
tree_view._delete_node(child.id)
tree_view.handle_delete_node(child.id)
assert child.id not in tree_view._state.items
assert child.id not in parent.children
@@ -225,7 +225,7 @@ class TestTreeviewBehaviour:
# Try to delete parent (has children)
with pytest.raises(ValueError, match="Cannot delete node.*with children"):
tree_view._delete_node(parent.id)
tree_view.handle_delete_node(parent.id)
def test_i_cannot_add_sibling_to_root(self, root_instance):
"""Test that adding sibling to root node raises an error."""
@@ -243,7 +243,7 @@ class TestTreeviewBehaviour:
# Try to select node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._select_node("nonexistent_id")
tree_view.handle_select_node("nonexistent_id")
def test_add_node_prevents_duplicate_children(self, root_instance):
"""Test that add_node prevents adding duplicate child IDs."""
@@ -317,11 +317,11 @@ class TestTreeviewBehaviour:
tree_view.add_node(child, parent_id=parent.id)
# Select the child
tree_view._select_node(child.id)
tree_view.handle_select_node(child.id)
assert tree_view._state.selected == child.id
# Delete the selected child
tree_view._delete_node(child.id)
tree_view.handle_delete_node(child.id)
# Selection should be cleared
assert tree_view._state.selected is None
@@ -340,7 +340,7 @@ class TestTreeviewBehaviour:
assert parent.id in tree_view._state.opened
# Delete the child (making parent a leaf)
tree_view._delete_node(child.id)
tree_view.handle_delete_node(child.id)
# Now delete the parent (now a leaf node)
# First remove it from root by creating a grandparent
@@ -349,7 +349,7 @@ class TestTreeviewBehaviour:
parent.parent = grandparent.id
grandparent.children.append(parent.id)
tree_view._delete_node(parent.id)
tree_view.handle_delete_node(parent.id)
# Parent should be removed from opened list
assert parent.id not in tree_view._state.opened
@@ -360,7 +360,7 @@ class TestTreeviewBehaviour:
# Try to start rename on node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._start_rename("nonexistent_id")
tree_view.handle_start_rename("nonexistent_id")
def test_i_cannot_save_rename_nonexistent_node(self, root_instance):
"""Test that saving rename for nonexistent node raises error."""
@@ -368,7 +368,7 @@ class TestTreeviewBehaviour:
# Try to save rename for node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._save_rename("nonexistent_id", "New Name")
tree_view.handle_save_rename("nonexistent_id", "New Name")
def test_i_cannot_add_sibling_to_nonexistent_node(self, root_instance):
"""Test that adding sibling to nonexistent node raises error."""
@@ -597,11 +597,11 @@ class TestTreeviewBehaviour:
tree_view.add_node(node2)
# Start editing node1
tree_view._start_rename(node1.id)
tree_view.handle_start_rename(node1.id)
assert tree_view._state.editing == node1.id
# Select node2
tree_view._select_node(node2.id)
tree_view.handle_select_node(node2.id)
# Edit mode should be cancelled
assert tree_view._state.editing is None
@@ -615,11 +615,11 @@ class TestTreeviewBehaviour:
tree_view.add_node(node)
# Start editing the node
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
assert tree_view._state.editing == node.id
# Select the same node
tree_view._select_node(node.id)
tree_view.handle_select_node(node.id)
# Edit mode should be cancelled
assert tree_view._state.editing is None
@@ -784,7 +784,7 @@ class TestTreeViewRender:
"""
node = TreeNode(label="Selected Node", type="file")
tree_view.add_node(node)
tree_view._select_node(node.id)
tree_view.handle_select_node(node.id)
rendered = tree_view.render()
selected_container = find_one(rendered, Div(data_node_id=node.id))
@@ -814,7 +814,7 @@ class TestTreeViewRender:
"""
node = TreeNode(label="Edit Me", type="file")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
rendered = tree_view.render()
editing_container = find_one(rendered, Div(data_node_id=node.id))
@@ -1009,7 +1009,7 @@ class TestTreeViewRender:
"""
node = TreeNode(label="Edit Me", type="file")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view.handle_start_rename(node.id)
# Step 1: Extract the input element
rendered = tree_view.render()

View File

@@ -350,3 +350,32 @@ class TestPresets:
assert "background-color: purple" in css.css
assert "color: yellow" in css.css
def test_i_can_expand_rule_preset_via_style(self):
"""A rule preset referenced via style() must be expanded like via format().
Why: style("traffic_light") should expand the traffic_light rule preset
(which has conditional style rules) instead of looking up "traffic_light"
as a style preset name (where it doesn't exist).
"""
engine = FormattingEngine()
rules = [FormatRule(style=Style(preset="traffic_light"))]
css, _ = engine.apply_format(rules, cell_value=-5)
assert css is not None
assert css.cls == "mf-formatting-error"
def test_i_can_expand_rule_preset_via_style_with_no_match(self):
"""A rule preset via style() with a non-matching condition returns no style.
Why: traffic_light has style("error") only if value < 0.
A positive value should produce no style output.
"""
engine = FormattingEngine()
rules = [FormatRule(style=Style(preset="traffic_light"))]
css, _ = engine.apply_format(rules, cell_value=10)
assert css is not None
assert css.cls == "mf-formatting-success"