Refactoring DataGrid to use DataService.py

This commit is contained in:
2026-03-02 22:34:14 +01:00
parent 0a766581ed
commit 30a77d1171
12 changed files with 1349 additions and 656 deletions

View File

@@ -0,0 +1,955 @@
import pandas as pd
import pytest
from fasthtml.components import Div, Script
from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
from myfasthtml.controls.DataGridsManager import DataGridsManager
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.core.constants import ColumnType
from myfasthtml.core.data.DataServicesManager import DataServicesManager
from myfasthtml.core.instances import InstancesManager
from myfasthtml.test.matcher import AnyValue, Contains, NoChildren, find, find_one, matches, TestLabel, TestObject
@pytest.fixture
def datagrids_manager(root_instance):
"""Create a DataGridsManager instance for testing."""
InstancesManager.reset()
TabsManager(root_instance) # just define it
return DataGridsManager(root_instance)
@pytest.fixture
def datagrid(datagrids_manager):
"""Create an empty DataGrid for testing."""
return DataGrid(datagrids_manager)
@pytest.fixture
def datagrid_with_data(datagrids_manager):
"""Create a DataGrid loaded with a sample DataFrame (name, age, active columns)."""
session = datagrids_manager._session
dsm = InstancesManager.get_by_type(session, DataServicesManager)
df = pd.DataFrame({
"name": ["Alice", "Bob", "Charlie"],
"age": [25, 30, 35],
"active": [True, False, True],
})
data_service = dsm.create_service("test.grid1", save_state=False)
data_service.load_dataframe(df)
grid_id = DataGrid.get_grid_id_from_data_service_id(data_service.get_id())
conf = DatagridConf(namespace="test", name="grid1")
return DataGrid(datagrids_manager, conf=conf, save_state=False, _id=grid_id)
@pytest.fixture
def datagrid_with_full_data(datagrids_manager):
"""Create a DataGrid covering all basic column types: Text, Number and Bool.
Designed to be extended with additional column types (Datetime, Enum, Formula)
as they are added to the test suite.
"""
session = datagrids_manager._session
dsm = InstancesManager.get_by_type(session, DataServicesManager)
df = pd.DataFrame({
"name": ["Alice", "Bob", "Charlie"], # Text
"age": [25, 30, 35], # Number
"active": [True, False, True], # Bool
})
data_service = dsm.create_service("test.full_types", save_state=False)
data_service.load_dataframe(df)
grid_id = DataGrid.get_grid_id_from_data_service_id(data_service.get_id())
conf = DatagridConf(namespace="test", name="full_types")
dg = DataGrid(datagrids_manager, conf=conf, save_state=False, _id=grid_id)
# Assign distinct widths (name=150, age=80, active=120) so tests that rely
# on column widths are not masked by a uniform default value of 100px.
for col_ui_state, width in zip(dg._state.columns, [150, 80, 120]):
col_ui_state.width = width
dg._init_columns()
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):
dg = DataGrid(datagrids_manager)
assert dg is not None
# ------------------------------------------------------------------
# Table Name
# ------------------------------------------------------------------
def test_i_can_get_table_name_with_namespace(self, datagrids_manager):
"""Test that get_table_name() returns 'namespace.name' when a namespace is set.
The dot-separated format is required by FormulaEngine for cross-table
reference resolution.
"""
conf = DatagridConf(namespace="reports", name="sales")
dg = DataGrid(datagrids_manager, conf=conf)
assert dg.get_table_name() == "reports.sales"
def test_i_can_get_table_name_without_namespace(self, datagrids_manager):
"""Test that get_table_name() returns just the name when namespace is absent.
Grids without a namespace use the plain name, which is valid for
single-namespace applications.
"""
conf = DatagridConf(name="employees")
dg = DataGrid(datagrids_manager, conf=conf)
assert dg.get_table_name() == "employees"
# ------------------------------------------------------------------
# Element ID Generation
# ------------------------------------------------------------------
@pytest.mark.parametrize("mode, pos, expected_template", [
("row", (2, 5), "trow_{id}-5"),
("column", (2, 5), "tcol_{id}-2"),
("cell", (2, 5), "tcell_{id}-2-5"),
])
def test_i_can_get_element_id_from_pos(self, datagrid, mode, pos, expected_template):
"""Test that _get_element_id_from_pos generates the correct element ID per selection mode.
Why these cases matter:
- 'row' mode: ID references the row index (pos[1]) to target HTMX row updates.
- 'column' mode: ID references the column index (pos[0]) for column highlighting.
- 'cell' mode: ID includes both indices for precise cell targeting and navigation.
"""
result = datagrid._get_element_id_from_pos(mode, pos)
expected = expected_template.format(id=datagrid._id)
assert result == expected
def test_i_can_get_element_id_when_pos_is_none(self, datagrid):
"""Test that _get_element_id_from_pos returns None when position is None.
None position means no cell is selected; the caller must receive None
to know that no DOM element should be targeted.
"""
assert datagrid._get_element_id_from_pos("cell", None) is None
# ------------------------------------------------------------------
# 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.
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.
"""
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
# ------------------------------------------------------------------
# Static ID Conversions
# ------------------------------------------------------------------
def test_i_can_convert_grid_id_to_data_service_id_and_back(self, datagrid):
"""Test that the grid↔data-service ID conversion is a perfect round-trip.
DataGrid and DataService share the same UUID but use different prefixes.
The round-trip property ensures neither conversion loses information, so
DataGrid can always locate its companion DataService from its own ID.
"""
grid_id = datagrid.get_id()
data_service_id = DataGrid.get_data_service_id_from_data_grid_id(grid_id)
assert data_service_id != grid_id
assert DataGrid.get_grid_id_from_data_service_id(data_service_id) == grid_id
# ------------------------------------------------------------------
# Column Management
# ------------------------------------------------------------------
def test_i_can_move_column(self, datagrid_with_data):
"""Test that move_column reorders _state.columns correctly.
Column order in _state.columns drives both header and body rendering.
Moving the last column to the first position verifies the pop-then-insert
logic for the source-after-target case.
"""
dg = datagrid_with_data
initial_order = [c.col_id for c in dg._state.columns]
last_col_id = initial_order[-1]
first_col_id = initial_order[0]
dg.move_column(last_col_id, first_col_id)
new_order = [c.col_id for c in dg._state.columns]
assert new_order[0] == last_col_id
# init_columns keeps the order
dg._init_columns()
assert [c.col_id for c in dg._columns][1:] == new_order
def test_i_cannot_move_column_when_column_not_found(self, datagrid_with_data):
"""Test that move_column does not alter state when a col_id is unknown.
An unknown column ID must be silently ignored (logged as a warning) so
that stale JS events cannot corrupt the column order.
"""
dg = datagrid_with_data
initial_order = [c.col_id for c in dg._state.columns]
dg.move_column("nonexistent_col", initial_order[0])
assert [c.col_id for c in dg._state.columns] == initial_order
def test_i_can_move_column_to_same_position(self, datagrid_with_data):
"""Test that move_column is a no-op when source and target are the same column.
Dropping a column header back onto itself must not change the order,
preventing unnecessary state saves and re-renders.
"""
dg = datagrid_with_data
initial_order = [c.col_id for c in dg._state.columns]
first_col_id = initial_order[0]
dg.move_column(first_col_id, first_col_id)
assert [c.col_id for c in dg._state.columns] == initial_order
def test_i_can_set_column_width(self, datagrid_with_data):
"""Test that handle_set_column_width persists the new pixel width in state.
Column width in _state.columns is the source of truth for header and
body cell sizing. A correct update ensures the resized width survives
the next render cycle.
"""
dg = datagrid_with_data
col_id = dg._state.columns[0].col_id
dg.handle_set_column_width(col_id, "300")
col = next(c for c in dg._state.columns if c.col_id == col_id)
assert col.width == 300
# ------------------------------------------------------------------
# Column Width Calculation
# ------------------------------------------------------------------
def test_i_can_calculate_optimal_column_width_returns_default_for_unknown_column(self, datagrid):
"""Test that calculate_optimal_column_width returns 150 for an unknown column.
A safe default prevents layout breakage when a col_id from JS does not
match any known column (e.g. after a column was deleted mid-session).
"""
result = datagrid.calculate_optimal_column_width("nonexistent_col")
assert result == 150
@pytest.mark.parametrize("col_title, expected_width", [
("name", 86), # data dominates: max("name"=4, "Charlie"=7) → 7*8+30=86
("age", 54), # title dominates: max("age"=3, "35"=2) → 3*8+30=54
("active", 78), # title dominates: max("active"=6, "False"=5) → 6*8+30=78
])
def test_i_can_calculate_optimal_column_width_with_data(self, datagrid_with_data, col_title, expected_width):
"""Test that calculate_optimal_column_width returns the correct pixel width based on content.
The formula max(title_length, max_data_length) * 8 + 30 must produce the
right width for each column so that the auto-size feature fits all visible
content without truncation.
Why these cases matter:
- 'name' (data dominates): "Charlie" (7 chars) > "name" (4 chars) → 86px
- 'age' (title dominates): "age" (3 chars) > "35" (2 chars) → 54px
- 'active' (title dominates): "active" (6 chars) > "False" (5 chars) → 78px
"""
dg = datagrid_with_data
col = next(c for c in dg._columns if c.title == col_title)
result = dg.calculate_optimal_column_width(col.col_id)
assert result == expected_width
# ------------------------------------------------------------------
# Selection and Interaction
# ------------------------------------------------------------------
def test_i_can_on_key_pressed_esc_clears_selection(self, datagrid):
"""Test that pressing ESC resets both the focused cell and extra selections.
ESC is the standard 'deselect all' shortcut. Both selected and
extra_selected must be cleared so the grid visually deselects everything
and subsequent navigation starts from a clean state.
"""
dg = datagrid
dg._state.selection.selected = (1, 2)
dg._state.selection.extra_selected.append(("range", (0, 0, 2, 2)))
dg.on_key_pressed("esc", has_focus=True, is_inside=True)
assert dg._state.selection.selected is None
assert dg._state.selection.extra_selected == []
def test_i_can_on_click_outside_does_not_update_position(self, datagrid):
"""Test that a click outside the grid does not change the selected position.
Clicks on surrounding UI elements must not accidentally move the grid
cursor, so is_inside=False must be a complete no-op for selection state.
"""
dg = datagrid
dg._state.selection.selected = (1, 2)
dg.on_click("click", is_inside=False, cell_id=f"tcell_{dg._id}-1-2")
assert dg._state.selection.selected == (1, 2)
def test_i_can_on_click_on_cell_updates_position(self, datagrid):
"""Test that clicking a cell sets selection.selected to the cell's (col, row) position.
The selected position drives the focus highlight rendered by
mk_selection_manager. Correct parsing and storage ensures the right cell
is visually highlighted after each click.
"""
dg = datagrid
cell_id = f"tcell_{dg._id}-2-5"
dg.on_click("click", is_inside=True, cell_id=cell_id)
assert dg._state.selection.selected == (2, 5)
def test_i_can_change_selection_mode(self, datagrid):
"""Test that change_selection_mode updates selection_mode from the CycleStateControl.
The selection mode (row / column / cell) determines how clicks and
keyboard events interpret the selected position. The grid must store
the mode chosen by the cycle selector so rendering and JS handlers
receive a consistent value.
"""
dg = datagrid
assert dg._state.selection.selection_mode == "cell"
dg._selection_mode_selector.cycle_state()
dg.change_selection_mode()
assert dg._state.selection.selection_mode == "row"
dg._selection_mode_selector.cycle_state()
dg.change_selection_mode()
assert dg._state.selection.selection_mode == "column"
dg._selection_mode_selector.cycle_state()
dg.change_selection_mode()
assert dg._state.selection.selection_mode == "cell"
class TestDataGridRender:
# ------------------------------------------------------------------
# Global structure (UTR-11.1)
# ------------------------------------------------------------------
def test_no_data_layout_is_rendered(self, datagrid):
"""Test that DataGrid renders a placeholder when no DataFrame is loaded.
Why these elements matter:
- Div tag: render must return a valid element, not raise
- "No data to display !": gives clear feedback and prevents a crash
when the DataService has no loaded DataFrame
"""
html = datagrid.render()
assert matches(html, Div("No data to display !"))
def test_layout_is_rendered(self, datagrid_with_data):
"""Test that DataGrid renders all 4 main structural sections when data is loaded.
Why these elements matter:
- id=dg._id: root ID required for JS init (initDataGrid) and HTMX targeting
- cls Contains 'grid': CSS grid layout controls header/body row sizing
- child[0] Div: filter bar + toolbar icons
- child[1] DoNotCheck: Panel containing the scrollable table
- child[2] Script: initDataGrid call that activates JS behaviour
- child[3] DoNotCheck: Keyboard handler for in-grid shortcuts
"""
dg = datagrid_with_data
html = dg.render()
expected = Div(
Div(
TestObject("Query"), # filter bar
Div(), # toolbar icons
cls=Contains("flex"),
),
TestObject("Panel"), # Panel containing the table
Script(Contains("initDataGrid")), # initDataGrid script
Div(), # Mouse and Keyboard handler
id=dg._id,
cls=Contains("grid"),
)
assert matches(html, expected)
# ------------------------------------------------------------------
# Selection Manager
# ------------------------------------------------------------------
def test_i_can_render_selection_manager_with_no_selection(self, datagrid):
"""Test that the selection manager renders with no children when nothing is selected.
Why these elements matter:
- id=tsm_{id}: required by updateDatagridSelection JS to locate the manager
- NoChildren: an empty manager signals JS that nothing is selected,
preventing stale highlight artefacts between renders
"""
dg = datagrid
html = dg.mk_selection_manager()
expected = Div(
NoChildren(),
id=f"tsm_{dg._id}",
)
assert matches(html, expected)
def test_i_can_render_selection_manager_with_selected_cell(self, datagrid):
"""Test that the selection manager renders one focus element when a cell is selected.
Why these elements matter:
- Div child: each selected element becomes a child directive read by JS
- selection_type='focus': tells JS to apply the focus highlight style
- element_id: the cell DOM ID that JS will scroll to and highlight
"""
dg = datagrid
dg._state.selection.selected = (2, 5)
html = dg.mk_selection_manager()
expected = Div(
Div(selection_type="focus", element_id=f"tcell_{dg._id}-2-5"),
id=f"tsm_{dg._id}",
)
assert matches(html, expected)
@pytest.mark.parametrize("mode", ["row", "column", "range", None])
def test_i_can_render_selection_manager_selection_mode_attribute(self, datagrid, mode):
"""Test that the selection_mode attribute on the manager Div reflects the current mode.
Why this element matters:
- selection_mode attribute: read by updateDatagridSelection JS to know which
highlight strategy to apply (row stripe, column stripe, range rectangle, or
single cell focus). All four valid values must round-trip correctly.
"""
dg = datagrid
dg._state.selection.selection_mode = mode
html = dg.mk_selection_manager()
expected = Div(
id=f"tsm_{dg._id}",
selection_mode=f"{mode}",
)
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'.
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
"""
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'.
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}"),
id=f"tsm_{dg._id}",
)
assert matches(html, expected)
def test_i_can_render_selection_manager_with_multiple_extra_selected(self, datagrid):
"""Test that all extra_selected entries are rendered as individual children.
Why these elements matter:
- 3 children: each extra selection must produce exactly one Div child;
missing entries would cause JS to miss highlights, extra entries would
cause phantom highlights on unselected cells
"""
dg = datagrid
dg._state.selection.extra_selected.append(("row", f"trow_{dg._id}-1"))
dg._state.selection.extra_selected.append(("column", f"tcol_{dg._id}-0"))
dg._state.selection.extra_selected.append(("range", (1, 1, 3, 3)))
html = dg.mk_selection_manager()
children = find(html, Div(selection_type=AnyValue()))
assert len(children) == 3, "Each extra_selected entry must produce exactly one child Div"
def test_i_can_render_selection_manager_with_focus_and_extra_selected(self, datagrid):
"""Test that a focused cell and an extra selection are both rendered as children.
Why these elements matter:
- focus child: the primary selection must always be present when selected is set
- extra child: secondary selections must not replace the focus entry; both must
coexist so JS can apply focus highlight and secondary highlights simultaneously
"""
dg = datagrid
dg._state.selection.selected = (2, 5)
dg._state.selection.extra_selected.append(("row", f"trow_{dg._id}-5"))
html = dg.mk_selection_manager()
expected = Div(
Div(selection_type="focus", element_id=f"tcell_{dg._id}-2-5"),
Div(selection_type="row", element_id=f"trow_{dg._id}-5"),
id=f"tsm_{dg._id}",
)
assert matches(html, expected)
# ------------------------------------------------------------------
# Headers
# ------------------------------------------------------------------
def test_i_can_render_headers(self, datagrid_with_data):
"""Test that the header row renders with the correct ID and one cell per visible data column.
Why these elements matter:
- id=th_{id}: targeted by reset_column_width to swap the header after auto-size
- cls Contains 'dt2-header': CSS hook for header styling and sticky positioning
- 3 data cells: one resizable cell per data column; verifies all DataFrame
columns are represented (RowSelection and add-button columns excluded)
"""
dg = datagrid_with_data
html = dg.mk_headers()
# Step 1: Validate global header structure
expected = Div(
id=f"th_{dg._id}",
cls=Contains("dt2-header"),
)
assert matches(html, expected)
# Step 2: Count the visible data column headers
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.
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
"""
dg = datagrid_with_data
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"
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.
Why this matters:
- The header order drives column alignment with body rows; a mismatch between
header and body column order produces visually broken tables where header
labels do not correspond to the values beneath them
- data-col: JS drag-and-drop uses this attribute to identify the column being
moved; it must match the col_id in _columns for move_column to work correctly
"""
dg = datagrid_with_data
html = dg.mk_headers()
expected_order = [c.col_id for c in dg._columns if c.type != ColumnType.RowSelection_]
rendered_cells = find(html, Div(cls=Contains("dt2-cell", "dt2-resizable")))
rendered_order = [cell.attrs.get("data-col") for cell in rendered_cells]
assert rendered_order == expected_order, (
f"Header column order {rendered_order} does not match _columns order {expected_order}"
)
def test_i_can_render_hidden_column_is_not_rendered(self, datagrid_with_data):
"""Test that a hidden column produces no header cell.
Why this matters:
- Columns with visible=False must be completely absent from the header so that
body cell alignment is preserved; a hidden header cell would shift all
subsequent column headers one position to the right
"""
dg = datagrid_with_data
dg._state.columns[0].visible = False
dg._init_columns()
html = dg.mk_headers()
col_headers = find(html, Div(cls=Contains("dt2-cell", "dt2-resizable")))
assert len(col_headers) == 2, "Hiding one column must reduce the resizable header count from 3 to 2"
@pytest.mark.parametrize("col_title, expected_icon", [
("name", "text_field20_regular"), # Text column
("age", "number_row20_regular"), # Number column
("active", "checkbox_checked20_filled"), # Bool column
])
def test_i_can_render_header_cell_with_correct_icon_for_column_type(
self, datagrid_with_full_data, col_title, expected_icon):
"""Test that each column header renders the correct type icon in its label.
Why these elements matter:
- TestLabel(col_title, icon): the label inside _mk_header_name must carry the
right icon so the user can visually identify the column type at a glance
- data-col: used to locate the cell in the tree; ensures we test the right column
- Parametrization covers the three fundamental types (Text, Number, Bool) so
that adding a new type requires only a new row in the parametrize decorator
"""
dg = datagrid_with_full_data
col = next(c for c in dg._columns if c.title == col_title)
html = dg.mk_headers()
# Step 1: Find the header cell for this column
cell = find_one(html, Div(data_col=col.col_id, cls=Contains("dt2-cell", "dt2-resizable")))
# Step 2: Verify the header name section contains the correct icon
expected = Div(
Div(
TestLabel(col_title, icon=expected_icon),
),
)
assert matches(cell, expected)
@pytest.mark.parametrize("col_title, expected_width", [
("name", 150),
("age", 80),
("active", 120),
])
def test_i_can_render_header_cell_width_matches_state(
self, datagrid_with_full_data, col_title, expected_width):
"""Test that the style attribute of each header cell reflects its width in _columns.
Why these elements matter:
- style Contains 'width:{n}px': the inline style is the sole mechanism that
sizes each column; a mismatch between _columns state and the rendered style
would produce misaligned headers and body cells after a resize operation
- Distinct widths (150 / 80 / 120) in the fixture guarantee that a correct
match cannot happen by accident with a uniform default value
"""
dg = datagrid_with_full_data
col = next(c for c in dg._columns if c.title == col_title)
html = dg.mk_headers()
cell = find_one(html, Div(data_col=col.col_id, cls=Contains("dt2-cell", "dt2-resizable")))
assert matches(cell, Div(style=Contains(f"width:{expected_width}px")))
def test_i_can_render_header_resize_handle_has_correct_commands(self, datagrid_with_full_data):
"""Test that every resizable header cell contains a resize handle with the correct command IDs.
Why these elements matter:
- dt2-resize-handle: the DOM target for JS drag-to-resize; its absence completely
disables column resizing for the affected column
- data-command-id: JS fires this command on mouseup to persist the new width to
the server; an incorrect ID would silently discard every resize action
- data-reset-command-id: JS fires this command on double-click to auto-size the
column; an incorrect ID would break the double-click reset feature
- 3 handles: one per data column; a missing handle disables resize for that column
"""
dg = datagrid_with_full_data
resize_cmd_id = dg.commands.set_column_width().id
reset_cmd_id = dg.commands.reset_column_width().id
html = dg.mk_headers()
handles = find(html, Div(
cls=Contains("dt2-resize-handle"),
data_command_id=resize_cmd_id,
data_reset_command_id=reset_cmd_id,
))
assert len(handles) == 3, "Each data column must have exactly one resize handle with correct command IDs"
# ------------------------------------------------------------------
# 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.
Why these elements matter:
- id=tb_{id}: targeted by get_page to append new rows during lazy loading
and by render_partial('body') to swap the whole body on filter/sort
- cls Contains 'dt2-body-container': CSS hook that enables the custom
JS scrollbar and overflow handling
"""
dg = datagrid_with_data
html = dg.mk_body_wrapper()
expected = Div(
id=f"tb_{dg._id}",
cls=Contains("dt2-body-container"),
)
assert matches(html, expected)
# ------------------------------------------------------------------
# Body rows
# ------------------------------------------------------------------
def test_i_can_render_body_row_count(self, datagrid_with_full_data):
"""Test that mk_body_content_page returns one row per DataFrame row plus the add-row button.
Why these elements matter:
- 3 rows: one per DataFrame row (Alice, Bob, Charlie); a missing row means data
was silently dropped during rendering
- 1 add-row button: required for edition mode; its absence disables row insertion
- total 4: the exact count prevents both missing and phantom rows
"""
dg = datagrid_with_full_data
rows = dg.mk_body_content_page(0)
assert len(rows) == 4, "Expected 3 data rows and 1 add-row button in edition mode"
def test_i_can_render_row_structure(self, datagrid_with_full_data):
"""Test that a rendered row carries the correct ID, CSS class and data-row attribute.
Why these elements matter:
- id=tr_{id}-{row_index}: targeted by handle_add_row (outerHTML swap) and lazy
loading; an incorrect ID breaks row-level HTMX updates
- cls Contains 'dt2-row': CSS hook for row styling and hover effects
- data_row: read by JS click handlers to identify which row was interacted with
"""
dg = datagrid_with_full_data
len_columns_1 = len(dg._columns) - 1
row = dg.mk_row(0, None, len_columns_1)
expected = Div(
id=f"tr_{dg._id}-0",
cls=Contains("dt2-row"),
data_row="0",
)
assert matches(row, expected)
def test_i_can_render_row_has_correct_cell_count(self, datagrid_with_full_data):
"""Test that each row renders exactly one cell per column in _columns.
Why this matters:
- cell count must equal column count: a mismatch shifts body cells out of
alignment with header cells, producing a visually broken table
- includes RowSelection_: the selection column must be present so the body
grid matches the header grid column by column
"""
dg = datagrid_with_full_data
len_columns_1 = len(dg._columns) - 1
row = dg.mk_row(0, None, len_columns_1)
assert len(row.children) == len(dg._columns), (
f"Row must have {len(dg._columns)} cells (one per column), got {len(row.children)}"
)
# ------------------------------------------------------------------
# Body cells
# ------------------------------------------------------------------
def test_i_can_render_body_cell_structure(self, datagrid_with_full_data):
"""Test that a data cell carries the correct ID, class, style and data-col attribute.
Why these elements matter:
- id=tcell_{id}-{col_pos}-{row_index}: parsed by _get_pos_from_element_id for
click/keyboard selection; an incorrect ID breaks cell-level navigation
- cls Contains 'dt2-cell': base CSS class for cell layout and borders
- style Contains 'width:{n}px': inline style that aligns the cell with its header;
a mismatch causes the column to appear misaligned after rendering
- data_col: read by JS resize drag to identify which column is being resized
"""
dg = datagrid_with_full_data
name_col = next(c for c in dg._columns if c.title == "name")
col_pos = dg._columns.index(name_col)
cell = dg.mk_body_cell(col_pos, 0, name_col, None, is_last=False)
expected = Div(
id=f"tcell_{dg._id}-{col_pos}-0",
cls=Contains("dt2-cell"),
style=Contains(f"width:{name_col.width}px"),
data_col=name_col.col_id,
)
assert matches(cell, expected)
def test_i_can_render_last_body_cell_has_dt2_last_cell_class(self, datagrid_with_full_data):
"""Test that the last cell in a row carries the dt2-last-cell class.
Why this element matters:
- dt2-last-cell: CSS hook that removes the right border on the final column
so the table edge looks clean; applied only to the last column to avoid
removing borders on intermediate cells
"""
dg = datagrid_with_full_data
last_col_pos = len(dg._columns) - 1
last_col_def = dg._columns[last_col_pos]
cell = dg.mk_body_cell(last_col_pos, 0, last_col_def, None, is_last=True)
assert matches(cell, Div(cls=Contains("dt2-cell", "dt2-last-cell")))
def test_i_can_render_body_cell_row_selection(self, datagrid_with_full_data):
"""Test that the RowSelection_ column renders a selection cell, not a data cell.
Why this element matters:
- dt2-row-selection: the DOM hook used by JS to toggle per-row checkboxes in
edition mode; it must not carry dt2-cell markup which would give it a data
cell appearance and break the selection column layout
"""
dg = datagrid_with_full_data
row_sel_col = dg._columns[0] # RowSelection_ is always inserted first
cell = dg.mk_body_cell(0, 0, row_sel_col, None, is_last=False)
assert matches(cell, Div(cls=Contains("dt2-row-selection")))
def test_i_cannot_render_body_cell_when_hidden(self, datagrid_with_full_data):
"""Test that mk_body_cell returns None for a column marked as not visible.
Why this matters:
- None return: the row renderer filters out None values so the hidden column
produces no DOM element; any non-None return would insert a phantom cell
and shift all subsequent cells one position to the right
"""
dg = datagrid_with_full_data
dg._state.columns[0].visible = False # hide "name" (first data column)
dg._init_columns()
hidden_col = next(c for c in dg._columns if c.title == "name")
col_pos = dg._columns.index(hidden_col)
cell = dg.mk_body_cell(col_pos, 0, hidden_col, None, is_last=False)
assert cell is None
# ------------------------------------------------------------------
# Body cell content
# ------------------------------------------------------------------
@pytest.mark.parametrize("col_title, expected_css_class, expected_value", [
("name", "dt2-cell-content-text", "Alice"),
("age", "dt2-cell-content-number", "25"),
("active", "dt2-cell-content-checkbox", None),
])
def test_i_can_render_body_cell_content_for_column_type(
self, datagrid_with_full_data, col_title, expected_css_class, expected_value):
"""Test that cell content carries the correct CSS class and value for each column type.
Why these elements matter:
- dt2-cell-content-text / number / checkbox: type-specific CSS classes that
control text alignment, font weight and boolean display; an incorrect class
makes numbers left-aligned or text right-aligned
- expected_value ('Alice', '25'): the actual DataFrame value must appear in the
rendered content so the cell is not empty or showing a wrong row
- Bool uses None for expected_value: the value is an icon, not a text string,
so only the wrapper class is verified
"""
dg = datagrid_with_full_data
col_def = next(c for c in dg._columns if c.title == col_title)
col_pos = dg._columns.index(col_def)
content = dg.mk_body_cell_content(col_pos, 0, col_def, None)
assert expected_css_class in str(content), (
f"Expected CSS class '{expected_css_class}' in cell content for column '{col_title}'"
)
if expected_value is not None:
assert expected_value in str(content), (
f"Expected value '{expected_value}' in cell content for column '{col_title}'"
)

View File

@@ -9,7 +9,7 @@ import pytest
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
from myfasthtml.controls.DataGridsManager import DataGridsManager
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowUiState
from myfasthtml.core.constants import ColumnType
from myfasthtml.core.formatting.dataclasses import FormatRule, Style
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
@@ -38,8 +38,8 @@ def datagrid(manager):
# Add some rows
grid._state.rows = [
DataGridRowState(0),
DataGridRowState(1),
DataGridRowUiState(0),
DataGridRowUiState(1),
]
yield grid

View File

@@ -3,9 +3,11 @@ import shutil
import pytest
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.controls.DataGridsManager import DataGridsManager
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeNode
from myfasthtml.core.data.DataServicesManager import DataServicesManager
from myfasthtml.core.instances import InstancesManager
from .conftest import root_instance
@@ -16,7 +18,7 @@ def cleanup_db():
@pytest.fixture
def datagrid_manager(root_instance):
def datagrids_manager(root_instance):
"""Create a DataGridsManager instance for testing."""
InstancesManager.reset()
TabsManager(root_instance) # just define it
@@ -26,7 +28,7 @@ def datagrid_manager(root_instance):
class TestDataGridsManagerBehaviour:
"""Tests for DataGridsManager behavior and logic."""
def test_i_can_create_new_grid_with_nothing_selected(self, datagrid_manager):
def test_i_can_create_new_grid_with_nothing_selected(self, datagrids_manager):
"""Test creating a new grid when no node is selected.
Verifies that:
@@ -35,10 +37,10 @@ class TestDataGridsManagerBehaviour:
- Node is selected and in edit mode
- Document definition is created
"""
result = datagrid_manager.new_grid()
datagrids_manager.handle_new_grid()
# Verify tree structure
tree = datagrid_manager._tree
tree = datagrids_manager._tree
assert len(tree._state.items) == 2, "Should have Untitled folder + Sheet1 node"
# Find the Untitled folder and Sheet1 node
@@ -55,13 +57,13 @@ class TestDataGridsManagerBehaviour:
assert tree._state.editing == sheet.id, "Sheet1 should be in edit mode"
# Verify document definition
assert len(datagrid_manager._state.elements) == 1, "Should have one document"
doc = datagrid_manager._state.elements[0]
assert len(datagrids_manager._state.elements) == 1, "Should have one document"
doc = datagrids_manager._state.elements[0]
assert doc.namespace == "Untitled"
assert doc.name == "Sheet1"
assert doc.type == "excel"
def test_i_can_create_new_grid_under_selected_folder(self, datagrid_manager):
def test_i_can_create_new_grid_under_selected_folder(self, datagrids_manager):
"""Test creating a new grid when a folder is selected.
Verifies that:
@@ -69,24 +71,24 @@ class TestDataGridsManagerBehaviour:
- Namespace matches folder name
"""
# Create a folder and select it
folder_id = datagrid_manager._tree.ensure_path("MyFolder")
datagrid_manager._tree._select_node(folder_id)
folder_id = datagrids_manager._tree.ensure_path("MyFolder")
datagrids_manager._tree._select_node(folder_id)
result = datagrid_manager.new_grid()
result = datagrids_manager.handle_new_grid()
# Verify the new grid is under MyFolder
tree = datagrid_manager._tree
tree = datagrids_manager._tree
nodes = list(tree._state.items.values())
sheet = [n for n in nodes if n.label == "Sheet1"][0]
assert sheet.parent == folder_id, "Sheet1 should be under MyFolder"
# Verify document definition
doc = datagrid_manager._state.elements[0]
doc = datagrids_manager._state.elements[0]
assert doc.namespace == "MyFolder"
assert doc.name == "Sheet1"
def test_i_can_create_new_grid_under_selected_leaf_parent(self, datagrid_manager):
def test_i_can_create_new_grid_under_selected_leaf_parent(self, datagrids_manager):
"""Test creating a new grid when a leaf node is selected.
Verifies that:
@@ -94,62 +96,62 @@ class TestDataGridsManagerBehaviour:
- Not under the leaf itself
"""
# Create a folder with a leaf
folder_id = datagrid_manager._tree.ensure_path("MyFolder")
folder_id = datagrids_manager._tree.ensure_path("MyFolder")
leaf = TreeNode(label="ExistingSheet", type="excel", parent=folder_id)
datagrid_manager._tree.add_node(leaf, parent_id=folder_id)
datagrids_manager._tree.add_node(leaf, parent_id=folder_id)
# Select the leaf
datagrid_manager._tree._select_node(leaf.id)
datagrids_manager._tree._select_node(leaf.id)
result = datagrid_manager.new_grid()
result = datagrids_manager.handle_new_grid()
# Verify the new grid is under MyFolder (not under ExistingSheet)
tree = datagrid_manager._tree
tree = datagrids_manager._tree
nodes = list(tree._state.items.values())
new_sheet = [n for n in nodes if n.label == "Sheet1"][0]
assert new_sheet.parent == folder_id, "Sheet1 should be under MyFolder (leaf's parent)"
assert new_sheet.parent != leaf.id, "Sheet1 should not be under the leaf"
def test_new_grid_generates_unique_sheet_names(self, datagrid_manager):
def test_new_grid_generates_unique_sheet_names(self, datagrids_manager):
"""Test that new_grid generates unique sequential sheet names.
Verifies Sheet1, Sheet2, Sheet3... generation.
"""
# Create first grid
datagrid_manager.new_grid()
assert datagrid_manager._state.elements[0].name == "Sheet1"
datagrids_manager.handle_new_grid()
assert datagrids_manager._state.elements[0].name == "Sheet1"
# Create second grid
datagrid_manager.new_grid()
assert datagrid_manager._state.elements[1].name == "Sheet2"
datagrids_manager.handle_new_grid()
assert datagrids_manager._state.elements[1].name == "Sheet2"
# Create third grid
datagrid_manager.new_grid()
assert datagrid_manager._state.elements[2].name == "Sheet3"
datagrids_manager.handle_new_grid()
assert datagrids_manager._state.elements[2].name == "Sheet3"
def test_new_grid_expands_parent_folder(self, datagrid_manager):
def test_new_grid_expands_parent_folder(self, datagrids_manager):
"""Test that creating a new grid automatically expands the parent folder.
Verifies parent is added to tree._state.opened.
"""
result = datagrid_manager.new_grid()
result = datagrids_manager.handle_new_grid()
tree = datagrid_manager._tree
tree = datagrids_manager._tree
nodes = list(tree._state.items.values())
untitled = [n for n in nodes if n.label == "Untitled"][0]
# Verify parent is expanded
assert untitled.id in tree._state.opened, "Parent folder should be expanded"
def test_new_grid_selects_and_edits_new_node(self, datagrid_manager):
def test_new_grid_selects_and_edits_new_node(self, datagrids_manager):
"""Test that new grid node is both selected and in edit mode.
Verifies _state.selected and _state.editing are set to new node.
"""
result = datagrid_manager.new_grid()
result = datagrids_manager.handle_new_grid()
tree = datagrid_manager._tree
tree = datagrids_manager._tree
nodes = list(tree._state.items.values())
sheet = [n for n in nodes if n.label == "Sheet1"][0]
@@ -159,17 +161,17 @@ class TestDataGridsManagerBehaviour:
# Verify edit mode
assert tree._state.editing == sheet.id, "New node should be in edit mode"
def test_new_grid_creates_document_definition(self, datagrid_manager):
def test_new_grid_creates_document_definition(self, datagrids_manager):
"""Test that new_grid creates a DocumentDefinition with correct fields.
Verifies document_id, namespace, name, type, tab_id, datagrid_id.
"""
result = datagrid_manager.new_grid()
result = datagrids_manager.handle_new_grid()
# Verify document was created
assert len(datagrid_manager._state.elements) == 1, "Should have one document"
assert len(datagrids_manager._state.elements) == 1, "Should have one document"
doc = datagrid_manager._state.elements[0]
doc = datagrids_manager._state.elements[0]
# Verify all fields
assert doc.document_id is not None, "Should have document_id"
@@ -180,34 +182,34 @@ class TestDataGridsManagerBehaviour:
assert doc.tab_id is not None, "Should have tab_id"
assert doc.datagrid_id is not None, "Should have datagrid_id"
def test_new_grid_creates_datagrid_and_registers(self, datagrid_manager):
def test_new_grid_creates_datagrid_and_registers(self, datagrids_manager):
"""Test that new_grid creates a DataGrid and registers it.
Verifies DataGrid exists and is in registry with namespace.name.
"""
result = datagrid_manager.new_grid()
result = datagrids_manager.handle_new_grid()
doc = datagrid_manager._state.elements[0]
doc = datagrids_manager._state.elements[0]
# Verify DataGrid is registered
entries = datagrid_manager._registry.get_all_entries()
entries = datagrids_manager._registry.get_all_entries()
assert doc.datagrid_id in entries, "DataGrid should be registered by grid_id"
assert entries[doc.datagrid_id] == ("Untitled", "Sheet1"), "Registry entry should match namespace and name"
# Verify DataGrid exists in InstancesManager
from myfasthtml.core.instances import InstancesManager
datagrid = InstancesManager.get(datagrid_manager._session, doc.datagrid_id, None)
datagrid = InstancesManager.get(datagrids_manager._session, doc.datagrid_id, None)
assert datagrid is not None, "DataGrid instance should exist"
def test_new_grid_creates_tab_with_datagrid(self, datagrid_manager):
def test_new_grid_creates_tab_with_datagrid(self, datagrids_manager):
"""Test that new_grid creates a tab with correct label and content.
Verifies tab is created via TabsManager with DataGrid as content.
"""
result = datagrid_manager.new_grid()
result = datagrids_manager.handle_new_grid()
doc = datagrid_manager._state.elements[0]
tabs_manager = datagrid_manager._tabs_manager
doc = datagrids_manager._state.elements[0]
tabs_manager = datagrids_manager._tabs_manager
# Verify tab exists in TabsManager
assert doc.tab_id in tabs_manager._state.tabs, "Tab should exist in TabsManager"
@@ -216,47 +218,82 @@ class TestDataGridsManagerBehaviour:
tab_metadata = tabs_manager._state.tabs[doc.tab_id]
assert tab_metadata['label'] == "Sheet1", "Tab label should be Sheet1"
def test_generate_unique_sheet_name_with_no_children(self, datagrid_manager):
def test_i_can_access_data_service_after_new_grid(self, datagrids_manager):
"""Test that a DataService is accessible via datagrid_id after new_grid().
Why this matters:
- DataService is now created first and its id becomes the datagrid_id
- Verifies the DataService is properly registered in DataServicesManager
- Ensures the link between datagrid_id and DataService is established
"""
datagrids_manager.handle_new_grid()
doc = datagrids_manager._state.elements[0]
datagrid_id = doc.datagrid_id
dataservice_id = DataGrid.get_data_service_id_from_data_grid_id(datagrid_id)
dataservice_manager = InstancesManager.get_by_type(datagrids_manager._session, DataServicesManager)
service = dataservice_manager.get_service(dataservice_id)
assert service is not None, "DataService should be accessible via dataservice_id (taken from datagrid_id)"
def test_new_grid_data_service_has_correct_table_name(self, datagrids_manager):
"""Test that the DataService receives the correct table_name after new_grid().
Why this matters:
- table_name is now passed to create_service() before the DataGrid exists
- Verifies the namespace.name format is correctly built and assigned
"""
datagrids_manager.handle_new_grid()
doc = datagrids_manager._state.elements[0]
dataservice_manager = InstancesManager.get_by_type(datagrids_manager._session, DataServicesManager)
dataservice_id = DataGrid.get_data_service_id_from_data_grid_id(doc.datagrid_id)
data_service = dataservice_manager.get_service(dataservice_id)
assert data_service.table_name == "Untitled.Sheet1", "DataService table_name should be 'Untitled.Sheet1'"
def test_generate_unique_sheet_name_with_no_children(self, datagrids_manager):
"""Test _generate_unique_sheet_name on an empty folder.
Verifies it returns "Sheet1" when no children exist.
"""
folder_id = datagrid_manager._tree.ensure_path("EmptyFolder")
folder_id = datagrids_manager._tree.ensure_path("EmptyFolder")
name = datagrid_manager._generate_unique_sheet_name(folder_id)
name = datagrids_manager._generate_unique_sheet_name(folder_id)
assert name == "Sheet1", "Should generate Sheet1 for empty folder"
def test_generate_unique_sheet_name_with_existing_sheets(self, datagrid_manager):
def test_generate_unique_sheet_name_with_existing_sheets(self, datagrids_manager):
"""Test _generate_unique_sheet_name with existing sheets.
Verifies it generates the next sequential number.
"""
folder_id = datagrid_manager._tree.ensure_path("MyFolder")
folder_id = datagrids_manager._tree.ensure_path("MyFolder")
# Add Sheet1 and Sheet2 manually
sheet1 = TreeNode(label="Sheet1", type="excel", parent=folder_id)
sheet2 = TreeNode(label="Sheet2", type="excel", parent=folder_id)
datagrid_manager._tree.add_node(sheet1, parent_id=folder_id)
datagrid_manager._tree.add_node(sheet2, parent_id=folder_id)
datagrids_manager._tree.add_node(sheet1, parent_id=folder_id)
datagrids_manager._tree.add_node(sheet2, parent_id=folder_id)
name = datagrid_manager._generate_unique_sheet_name(folder_id)
name = datagrids_manager._generate_unique_sheet_name(folder_id)
assert name == "Sheet3", "Should generate Sheet3 when Sheet1 and Sheet2 exist"
def test_generate_unique_sheet_name_skips_gaps(self, datagrid_manager):
def test_generate_unique_sheet_name_skips_gaps(self, datagrids_manager):
"""Test _generate_unique_sheet_name fills gaps in sequence.
Verifies it generates Sheet2 when Sheet1 and Sheet3 exist (missing Sheet2).
"""
folder_id = datagrid_manager._tree.ensure_path("MyFolder")
folder_id = datagrids_manager._tree.ensure_path("MyFolder")
# Add Sheet1 and Sheet3 (skip Sheet2)
sheet1 = TreeNode(label="Sheet1", type="excel", parent=folder_id)
sheet3 = TreeNode(label="Sheet3", type="excel", parent=folder_id)
datagrid_manager._tree.add_node(sheet1, parent_id=folder_id)
datagrid_manager._tree.add_node(sheet3, parent_id=folder_id)
datagrids_manager._tree.add_node(sheet1, parent_id=folder_id)
datagrids_manager._tree.add_node(sheet3, parent_id=folder_id)
name = datagrid_manager._generate_unique_sheet_name(folder_id)
name = datagrids_manager._generate_unique_sheet_name(folder_id)
assert name == "Sheet2", "Should generate Sheet2 to fill the gap"