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

@@ -1,9 +1,7 @@
import json
import logging.config
import pandas as pd
import yaml
from dbengine.handlers import BaseRefHandler, handlers
from dbengine.handlers import handlers
from fasthtml import serve
from fasthtml.components import Div
@@ -15,7 +13,6 @@ from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.dbengine_utils import DataFrameHandler
from myfasthtml.core.instances import UniqueInstance
@@ -39,53 +36,6 @@ app, rt = create_app(protect_routes=True,
base_url="http://localhost:5003")
def create_sample_treeview(parent):
"""
Create a sample TreeView with a file structure for testing.
Args:
parent: Parent instance for the TreeView
Returns:
TreeView: Configured TreeView instance with sample data
"""
tree_view = TreeView(parent, _id="-treeview")
# Create sample file structure
projects = TreeNode(label="Projects", type="folder")
tree_view.add_node(projects)
myfasthtml = TreeNode(label="MyFastHtml", type="folder")
tree_view.add_node(myfasthtml, parent_id=projects.id)
app_py = TreeNode(label="app.py", type="file")
tree_view.add_node(app_py, parent_id=myfasthtml.id)
readme = TreeNode(label="README.md", type="file")
tree_view.add_node(readme, parent_id=myfasthtml.id)
src_folder = TreeNode(label="src", type="folder")
tree_view.add_node(src_folder, parent_id=myfasthtml.id)
controls_py = TreeNode(label="controls.py", type="file")
tree_view.add_node(controls_py, parent_id=src_folder.id)
documents = TreeNode(label="Documents", type="folder")
tree_view.add_node(documents, parent_id=projects.id)
notes = TreeNode(label="notes.txt", type="file")
tree_view.add_node(notes, parent_id=documents.id)
todo = TreeNode(label="todo.md", type="file")
tree_view.add_node(todo, parent_id=documents.id)
# Expand all nodes to show the full structure
# tree_view.expand_all()
return tree_view
@rt("/")
def index(session):
session_instance = UniqueInstance(session=session,
@@ -120,19 +70,15 @@ def index(session):
btn_popup = mk.label("Popup",
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
# Create TreeView with sample data
tree_view = create_sample_treeview(layout)
layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
layout.left_drawer.add(btn_file_upload, "Test")
layout.left_drawer.add(btn_popup, "Test")
layout.left_drawer.add(tree_view, "TreeView")
# data grids
dgs_manager = DataGridsManager(layout, _id="-datagrids")
dgs_manager = DataGridsManager(session_instance)
layout.left_drawer.add_group("Documents", Div("Documents",
dgs_manager.mk_main_icons(),
cls="mf-layout-group flex gap-3"))

View File

@@ -5,10 +5,8 @@ from dataclasses import dataclass
from functools import lru_cache
from typing import Optional
import pandas as pd
from fasthtml.common import NotStr
from fasthtml.components import *
from pandas import DataFrame
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.CycleStateControl import CycleStateControl
@@ -17,23 +15,28 @@ from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEdito
from myfasthtml.controls.DslEditor import DslEditorConf
from myfasthtml.controls.IconsHelper import IconsHelper
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, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.controls.helpers import mk, column_type_defaults
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowUiState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState, DataGridColumnUiState, \
DataGridRowSelectionColumnState
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID, \
ROW_SELECTION_ID
from myfasthtml.core.constants import ColumnType, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
from myfasthtml.core.data.ColumnDefinition import ColumnDefinition
from myfasthtml.core.data.DataService import DataService
from myfasthtml.core.data.DataServicesManager import DataServicesManager
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.formatting.dsl.completion.FormattingCompletionEngine import FormattingCompletionEngine
from myfasthtml.core.formatting.dsl.completion.provider import DatagridMetadataProvider
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
from myfasthtml.core.formatting.dsl.parser import DSLParser
from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.instances import MultipleInstance, InstancesManager
from myfasthtml.core.optimized_ft import OptimizedDiv
from myfasthtml.core.utils import make_safe_id, merge_classes, make_unique_safe_id, is_null
from myfasthtml.core.utils import merge_classes, is_null
from myfasthtml.icons.carbon import row, column, grid
from myfasthtml.icons.fluent import checkbox_unchecked16_regular
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular, column_edit20_regular, add12_filled
@@ -70,8 +73,8 @@ class DatagridState(DbObject):
super().__init__(owner, name=f"{owner.get_id()}#state", save_state=save_state)
self.sidebar_visible: bool = False
self.selected_view: str = None
self.columns: list[DataGridColumnState] = []
self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
self.columns: list[DataGridColumnUiState] = []
self.rows: list[DataGridRowUiState] = [] # only the rows that have a specific state
self.headers: list[DataGridHeaderFooterConf] = []
self.footers: list[DataGridHeaderFooterConf] = []
self.sorted: list = []
@@ -101,20 +104,6 @@ class DatagridSettings(DbObject):
self.enable_edition: bool = True
class DatagridStore(DbObject):
"""
Store Dataframes
"""
def __init__(self, owner, save_state):
with self.initializing():
super().__init__(owner, name=f"{owner.get_id()}#df", save_state=save_state)
self.ne_df = None
self.ns_fast_access = None
self.ns_row_data = None
self.ns_total_rows = None
class Commands(BaseCommands):
def get_page(self, page_index: int):
return Command("GetPage",
@@ -231,11 +220,15 @@ class DataGrid(MultipleInstance):
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._df_store = DatagridStore(self, save_state=self._settings.save_state)
self._formatting_engine = FormattingEngine()
self._columns = None
self.commands = Commands(self)
self.init_from_dataframe(self._df_store.ne_df, init_state=False) # data comes from DatagridStore
# Obtain DataService from DataServicesManager (no parent hierarchy)
data_services_manager = InstancesManager.get_by_type(self._session, DataServicesManager)
data_service_id = self.get_data_service_id_from_data_grid_id(self._id)
self._data_service = data_services_manager.restore_service(data_service_id)
self._init_columns()
# add Panel
self._panel = Panel(self, conf=PanelConf(show_display_right=False), _id="-panel")
@@ -253,15 +246,17 @@ class DataGrid(MultipleInstance):
# add Selection Selector
selection_types = {
"cell": mk.icon(grid, tooltip="Cell selection"), # default
"row": mk.icon(row, tooltip="Row selection"),
"column": mk.icon(column, tooltip="Column selection"),
"cell": mk.icon(grid, tooltip="Cell selection")
}
self._selection_mode_selector = CycleStateControl(self,
controls=selection_types,
save_state=False,
_id="-cycle_state")
self._selection_mode_selector.bind_command("CycleState", self.commands.change_selection_mode())
if self._state.selection.selection_mode is None:
self.change_selection_mode()
# add columns manager
self._columns_manager = DataGridColumnsManager(self)
@@ -269,7 +264,8 @@ class DataGrid(MultipleInstance):
self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed())
if self._settings.enable_formatting:
completion_engine = FormattingCompletionEngine(self._parent, self.get_table_name())
provider = InstancesManager.get_by_type(self._session, DatagridMetadataProvider, None)
completion_engine = FormattingCompletionEngine(provider, self.get_table_name())
editor_conf = DslEditorConf(engine_id=completion_engine.get_id())
dsl = FormattingDSL()
self._formatting_editor = DataGridFormattingEditor(self,
@@ -301,7 +297,13 @@ class DataGrid(MultipleInstance):
@property
def _df(self):
return self._df_store.ne_df
if self._data_service is None:
return None
return self._data_service.get_store().ne_df
@property
def _fast_access(self):
return self._data_service.get_store().ns_fast_access
def _apply_sort(self, df):
if df is None:
@@ -344,7 +346,8 @@ class DataGrid(MultipleInstance):
df = self._df.copy()
df = self._apply_sort(df) # need to keep the real type to sort
df = self._apply_filter(df)
self._df_store.ns_total_rows = len(df)
if self._data_service is not None:
self._data_service.get_store().ns_total_rows = len(df)
return df
@@ -375,36 +378,6 @@ class DataGrid(MultipleInstance):
self._state.selection.selected = pos
self._state.save()
def _register_existing_formulas(self) -> None:
"""
Re-register all formula columns with the FormulaEngine.
Called after data reload to ensure the engine knows about all
formula columns and their expressions.
"""
engine = self.get_formula_engine()
if engine is None:
return
table = self.get_table_name()
for col_def in self._state.columns:
if col_def.formula:
try:
engine.set_formula(table, col_def.col_id, col_def.formula)
except Exception as e:
logger.warning("Failed to register formula for %s.%s: %s", table, col_def.col_id, e)
def _recalculate_formulas(self) -> None:
"""
Recalculate dirty formula columns before rendering.
Called at the start of mk_body_content_page() to ensure formula
columns are up-to-date before cells are rendered.
"""
engine = self.get_formula_engine()
if engine is None:
return
engine.recalculate_if_needed(self.get_table_name(), self._df_store)
def _get_format_rules(self, col_pos, row_index, col_def):
"""
Get format rules for a cell, returning only the most specific level defined.
@@ -440,197 +413,55 @@ class DataGrid(MultipleInstance):
if self._state.table_format:
return self._state.table_format
# Get global tables formatting from manager
return self._parent.all_tables_formats
# Get global tables formatting from DatagridMetadataProvider
provider = InstancesManager.get_by_type(self._session, DatagridMetadataProvider, None)
return provider.all_tables_formats if provider is not None else []
def _init_columns(self):
self._columns = self._state.columns.copy()
# Populate UI state from DataService columns when creating a new grid
columns_defs = self._data_service.columns
columns = []
if self._state.columns:
# we need to make sure that we match the DataGridColumnUiState and the ColumnDefinition
for col_ui_state in self._state.columns:
col_def = [col_def for col_def in columns_defs if col_def.col_id == col_ui_state.col_id][0]
columns.append(DataGridColumnState(col_def, col_ui_state))
self._columns = columns
else:
# init the ui states
ui_states = [DataGridColumnUiState(col_def.col_id) for col_def in columns_defs]
self._state.columns = ui_states # this saves the state of the columns
self._columns = [DataGridColumnState(col_def, col_ui_state)
for col_def, col_ui_state in zip(columns_defs, ui_states)]
if self._settings.enable_edition:
self._columns.insert(0, DataGridColumnState(ROW_SELECTION_ID,
-1,
"",
ColumnType.RowSelection_))
def init_from_dataframe(self, df, init_state=True):
def _get_column_type(dtype):
if pd.api.types.is_integer_dtype(dtype):
return ColumnType.Number
elif pd.api.types.is_float_dtype(dtype):
return ColumnType.Number
elif pd.api.types.is_bool_dtype(dtype):
return ColumnType.Bool
elif pd.api.types.is_datetime64_any_dtype(dtype):
return ColumnType.Datetime
else:
return ColumnType.Text # Default to Text if no match
def _init_columns(_df):
columns = [DataGridColumnState(make_safe_id(col_id),
col_index,
col_id,
_get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(_df.columns)]
return columns
def _init_fast_access(_df):
"""
Generates a fast-access dictionary for a DataFrame.
This method converts the columns of the provided DataFrame into NumPy arrays
and stores them as values in a dictionary, using the column names as keys.
This allows for efficient access to the data stored in the DataFrame.
Args:
_df (DataFrame): The input pandas DataFrame whose columns are to be converted
into a dictionary of NumPy arrays.
Returns:
dict: A dictionary where the keys are the column names of the input DataFrame
and the values are the corresponding column values as NumPy arrays.
"""
if _df is None:
return {}
res = {col: _df[col].to_numpy() for col in _df.columns}
res[ROW_INDEX_ID] = _df.index.to_numpy()
return res
def _init_row_data(_df):
"""
Generates a list of row data dictionaries for column references in formatting conditions.
Each dict contains {col_id: value} for a single row, used by FormattingEngine
to evaluate conditions that reference other columns (e.g., {"col": "budget"}).
Args:
_df (DataFrame): The input pandas DataFrame.
Returns:
list[dict]: A list where each element is a dict of column values for that row.
"""
if _df is None:
return []
return _df.to_dict(orient='records')
if df is not None:
self._df_store.ne_df = df
if init_state:
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
self._df_store.save()
self._state.rows = [] # sparse: only rows with non-default state are stored
self._state.columns = _init_columns(df) # use df not self._df to keep the original title
self._init_columns()
self._df_store.ns_fast_access = _init_fast_access(self._df)
self._df_store.ns_row_data = _init_row_data(self._df)
self._df_store.ns_total_rows = len(self._df) if self._df is not None else 0
self._register_existing_formulas()
return self
self._columns.insert(0, DataGridRowSelectionColumnState())
def add_new_column(self, col_def: DataGridColumnState) -> None:
"""Add a new column to the DataGrid.
For Formula columns, only _state.columns is updated; the FormulaEngine
computes values on demand via recalculate_if_needed().
For other column types, also adds the column to the DataFrame and updates
ns_fast_access and ns_row_data incrementally with type-appropriate defaults.
"""Add a new column, delegating data mutation to DataService.
Args:
col_def: Column definition with title and type already set.
col_id is derived from title via make_safe_id.
col_def: Column definition with title and type set. col_id will be
assigned by DataService.
"""
col_def.col_id = make_unique_safe_id(col_def.title, [c.col_id for c in self._state.columns])
if col_def.type == ColumnType.Formula:
col_def.col_index = -1
self._state.columns.append(col_def)
return
default_value = column_type_defaults.get(col_def.type, "")
col_def.col_index = len(self._df.columns) if self._df is not None else 0
self._state.columns.append(col_def)
if self._df is not None:
self._df_store.ne_df[col_def.col_id] = default_value
self._df_store.ns_fast_access[col_def.col_id] = self._df_store.ne_df[col_def.col_id].to_numpy()
for row_dict in self._df_store.ns_row_data:
row_dict[col_def.col_id] = default_value
self._df_store.save()
def add_new_row(self, row_data: dict = None) -> None:
"""Add a new row to the DataGrid with incremental updates.
Creates default values based on column types and handles formula columns.
Updates ns_fast_access and ns_row_data incrementally for better performance.
Args:
row_data: Optional dict with initial values. If None, uses type defaults.
"""
import numpy as np
def _get_default_value(col_def: DataGridColumnState):
"""Get default value for a column based on its type."""
if col_def.type == ColumnType.Formula:
return None # Will be calculated by FormulaEngine
return column_type_defaults.get(col_def.type, "")
if row_data is None:
# Create default values for all non-formula, non-selection columns
row_data = {}
for col in self._state.columns:
if col.type not in (ColumnType.Formula, ColumnType.RowSelection_):
row_data[col.col_id] = _get_default_value(col)
# 1. Add row to DataFrame (only non-formula columns)
new_index = len(self._df)
self._df.loc[new_index] = row_data
# 2. Incremental update of ns_fast_access
for col_id, value in row_data.items():
if col_id in self._df_store.ns_fast_access:
self._df_store.ns_fast_access[col_id] = np.append(
self._df_store.ns_fast_access[col_id],
value
)
else:
# First value for this column (rare case)
self._df_store.ns_fast_access[col_id] = np.array([value])
# Update row index
if ROW_INDEX_ID in self._df_store.ns_fast_access:
self._df_store.ns_fast_access[ROW_INDEX_ID] = np.append(
self._df_store.ns_fast_access[ROW_INDEX_ID],
new_index
if self._data_service is not None:
data_col = ColumnDefinition(
col_id="",
col_index=col_def.col_index,
title=col_def.title,
type=col_def.type,
formula=col_def.formula,
)
else:
self._df_store.ns_fast_access[ROW_INDEX_ID] = np.array([new_index])
# 3. Incremental update of ns_row_data
self._df_store.ns_row_data.append(row_data.copy())
# 4. Handle formula columns
engine = self.get_formula_engine()
if engine is not None:
table_name = self.get_table_name()
# Check if there are any formula columns
has_formulas = any(col.type == ColumnType.Formula for col in self._state.columns)
if has_formulas:
try:
# Recalculate all formulas (engine handles efficiency)
engine.recalculate_if_needed(table_name, self._df_store)
except Exception as e:
logger.warning(f"Failed to calculate formulas for new row: {e}")
# 5. Update total row count
self._df_store.ns_total_rows = len(self._df)
# Save changes
self._df_store.save()
self._data_service.add_column(data_col)
col_def.col_id = data_col.col_id
col_def.col_index = data_col.col_index
self._state.columns.append(col_def)
self._init_columns()
self._state.save()
def move_column(self, source_col_id: str, target_col_id: str):
"""Move column to new position. Called via Command from JS."""
@@ -676,7 +507,7 @@ class DataGrid(MultipleInstance):
Returns:
Optimal width in pixels (between 50 and 500)
"""
col_def = next((c for c in self._state.columns if c.col_id == col_id), None)
col_def = next((c for c in self._columns if c.col_id == col_id), None)
if not col_def:
logger.warning(f"calculate_optimal_column_width: column not found {col_id=}")
return 150 # default width
@@ -686,8 +517,8 @@ class DataGrid(MultipleInstance):
# Max data length
max_data_length = 0
if col_id in self._df_store.ns_fast_access:
col_array = self._df_store.ns_fast_access[col_id]
if col_id in self._fast_access:
col_array = self._fast_access[col_id]
if col_array is not None and len(col_array) > 0:
max_data_length = max(len(str(v)) for v in col_array)
@@ -774,7 +605,6 @@ class DataGrid(MultipleInstance):
def change_selection_mode(self):
logger.debug(f"change_selection_mode")
new_state = self._selection_mode_selector.get_state()
logger.debug(f" {new_state=}")
self._state.selection.selection_mode = new_state
self._state.save()
return self.render_partial()
@@ -812,7 +642,8 @@ class DataGrid(MultipleInstance):
self._state.save()
def handle_add_row(self):
self.add_new_row() # Add row to data
if self._data_service is not None:
self._data_service.add_row()
row_index = len(self._df) - 1 # Index of the newly added row
len_columns_1 = len(self._columns) - 1
rows = [self.mk_row(row_index, None, len_columns_1)]
@@ -835,8 +666,17 @@ class DataGrid(MultipleInstance):
return f"{self._settings.namespace}.{self._settings.name}" if self._settings.namespace else self._settings.name
def get_formula_engine(self):
"""Return the FormulaEngine from the DataGridsManager, if available."""
return self._parent.get_formula_engine()
"""Return the shared FormulaEngine from DataServicesManager."""
manager = InstancesManager.get_by_type(self._session, DataServicesManager, None)
return manager.get_formula_engine() if manager is not None else None
@staticmethod
def get_grid_id_from_data_service_id(data_service_id):
return data_service_id.replace(DataService.compute_prefix(), DataGrid.compute_prefix(), 1)
@staticmethod
def get_data_service_id_from_data_grid_id(datagrid_id):
return datagrid_id.replace(DataGrid.compute_prefix(), DataService.compute_prefix(), 1)
def mk_headers(self):
resize_cmd = self.commands.set_column_width()
@@ -917,7 +757,7 @@ class DataGrid(MultipleInstance):
# Formula column: safe read — ns_fast_access entry may not exist yet if the
# engine hasn't run its first recalculation pass.
if column_type == ColumnType.Formula:
col_array = self._df_store.ns_fast_access.get(col_def.col_id)
col_array = self._fast_access.get(col_def.col_id)
if col_array is None or row_index >= len(col_array):
return NotStr('<span class="dt2-cell-content-text truncate">—</span>')
value = col_array[row_index]
@@ -932,7 +772,7 @@ class DataGrid(MultipleInstance):
else:
column_type = ColumnType.Text
else:
value = self._df_store.ns_fast_access[col_def.col_id][row_index]
value = self._fast_access[col_def.col_id][row_index]
# Boolean type - uses cached HTML (only 2 possible values)
if column_type == ColumnType.Bool:
@@ -981,7 +821,7 @@ class DataGrid(MultipleInstance):
if col_def.type == ColumnType.RowSelection_:
return OptimizedDiv(cls="dt2-row-selection")
col_array = self._df_store.ns_fast_access.get(col_def.col_id)
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)
@@ -1013,7 +853,8 @@ class DataGrid(MultipleInstance):
OPTIMIZED: Extract filter keyword once instead of 10,000 times.
OPTIMIZED: Uses OptimizedDiv for rows instead of Div for faster rendering.
"""
self._recalculate_formulas()
if self._data_service is not None:
self._data_service.ensure_ready()
df = self._get_filtered_df()
if df is None:
return []
@@ -1026,7 +867,8 @@ class DataGrid(MultipleInstance):
len_columns_1 = len(self._columns) - 1
rows = [self.mk_row(row_index, filter_keyword_lower, len_columns_1) for row_index in df.index[start:end]]
if self._df_store.ns_total_rows > end:
store = self._data_service.get_store() if self._data_service is not None else None
if store is not None and store.ns_total_rows > end:
rows[-1].attrs.extend(self.commands.get_page(page_index + 1).get_htmx_params(escaped=True))
self._mk_append_add_row_if_needed(rows)
@@ -1184,7 +1026,7 @@ class DataGrid(MultipleInstance):
)
def render(self):
if self._df_store.ne_df is None:
if self._df is None:
return Div("No data to display !")
return Div(
@@ -1201,8 +1043,10 @@ class DataGrid(MultipleInstance):
cls="flex items-center justify-between mb-2"),
self._panel.set_main(self.mk_table_wrapper()),
Script(f"initDataGrid('{self._id}');"),
# Mouse(self, combinations=self._mouse_support, _id="-mouse"),
Keyboard(self, combinations=self._key_support, _id="-keyboard"),
Div(
Mouse(self, combinations=self._mouse_support, _id="-mouse"),
Keyboard(self, combinations=self._key_support, _id="-keyboard"),
),
id=self._id,
cls="grid",
style="height: 100%; grid-template-rows: auto 1fr;"

View File

@@ -159,24 +159,20 @@ class DataGridColumnsManager(MultipleInstance):
return self.mk_column_details(col_def)
def _register_formula(self, col_def) -> None:
"""Register or remove a formula column with the FormulaEngine.
"""Register or remove a formula column with the FormulaEngine via DataService.
Registers only when col_def.type is Formula and the formula text is
non-empty. Removes the formula in all other cases so the engine stays
consistent with the column definition.
"""
engine = self._parent.get_formula_engine()
if engine is None:
data_service = getattr(self._parent, "_data_service", None)
if data_service is None:
return
table = self._parent.get_table_name()
if col_def.type == ColumnType.Formula and col_def.formula:
try:
engine.set_formula(table, col_def.col_id, col_def.formula)
logger.debug("Registered formula for %s.%s", table, col_def.col_id)
except Exception as e:
logger.warning("Formula error for %s.%s: %s", table, col_def.col_id, e)
data_service.register_formula(col_def.col_id, col_def.formula)
logger.debug("Registered formula for col %s", col_def.col_id)
else:
engine.remove_formula(table, col_def.col_id)
data_service.remove_formula(col_def.col_id)
def mk_column_label(self, col_def: DataGridColumnState):
return Div(

View File

@@ -2,7 +2,7 @@ import logging
from collections import defaultdict
from myfasthtml.controls.DslEditor import DslEditor
from myfasthtml.controls.datagrid_objects import DataGridRowState
from myfasthtml.controls.datagrid_objects import DataGridRowUiState
from myfasthtml.core.formatting.dsl import parse_dsl, DSLSyntaxError, ColumnScope, RowScope, CellScope, TableScope, TablesScope
from myfasthtml.core.instances import InstancesManager
@@ -111,7 +111,7 @@ class DataGridFormattingEditor(DslEditor):
for row_index, rules in rows_rules.items():
row_state = next((r for r in state.rows if r.row_id == row_index), None)
if row_state is None:
row_state = DataGridRowState(row_id=row_index)
row_state = DataGridRowUiState(row_id=row_index)
state.rows.append(row_state)
row_state.format = rules

View File

@@ -13,10 +13,8 @@ from myfasthtml.controls.TreeView import TreeView, TreeNode, TreeViewConf
from myfasthtml.controls.helpers import mk
from myfasthtml.core.DataGridsRegistry import DataGridsRegistry
from myfasthtml.core.commands import Command
from myfasthtml.core.data.DataServicesManager import DataServicesManager
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.formatting.dsl.completion.provider import DatagridMetadataProvider
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS
from myfasthtml.core.formula.engine import FormulaEngine
from myfasthtml.core.instances import InstancesManager, SingleInstance
from myfasthtml.icons.fluent_p1 import table_add20_regular
from myfasthtml.icons.fluent_p3 import folder_open20_regular
@@ -50,7 +48,7 @@ class Commands(BaseCommands):
return Command("NewGrid",
"New grid",
self._owner,
self._owner.new_grid).htmx(target=f"#{self._owner._tree.get_id()}")
self._owner.handle_new_grid).htmx(target=f"#{self._owner._tree.get_id()}")
def open_from_excel(self, tab_id, file_upload):
return Command("OpenFromExcel",
@@ -81,11 +79,16 @@ class Commands(BaseCommands):
key="DeleteNode")
class DataGridsManager(SingleInstance, DatagridMetadataProvider):
class DataGridsManager(SingleInstance):
"""UI manager for DataGrids.
Responsible for the visual organisation of DataGrids: TreeView, TabsManager,
and document lifecycle (create, open, delete). All data concerns are handled
by DataServicesManager and DataService.
"""
def __init__(self, parent, _id=None):
if not getattr(self, "_is_new_instance", False):
# Skip __init__ if instance already existed
return
super().__init__(parent, _id=_id)
self.commands = Commands(self)
@@ -96,53 +99,47 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager, None)
self._registry = DataGridsRegistry(parent)
# Global presets shared across all DataGrids
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
self.all_tables_formats: list = []
# Formula engine shared across all DataGrids in this session
self._formula_engine = FormulaEngine(
registry_resolver=self._resolve_store_for_table
)
# Data layer — session-scoped singletons
self._data_services_manager = DataServicesManager(self._parent)
def upload_from_source(self):
file_upload = FileUpload(self)
tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload)
file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload))
return self._tabs_manager.show_tab(tab_id)
# ------------------------------------------------------------------
# Grid lifecycle
# ------------------------------------------------------------------
def _create_and_register_grid(self, namespace: str, name: str, df: pd.DataFrame) -> DataGrid:
"""
Create and register a DataGrid.
"""Create a DataGrid and its companion DataService, then register both.
Args:
namespace: Grid namespace
name: Grid name
df: DataFrame to initialize the grid with
namespace: Grid namespace.
name: Grid name.
df: DataFrame to initialise the grid with.
Returns:
Created DataGrid instance
Created DataGrid instance.
"""
table_name = f"{namespace}.{name}" if namespace else name
data_service = self._data_services_manager.create_service(table_name, save_state=True)
data_service.load_dataframe(df)
grid_id = DataGrid.get_grid_id_from_data_service_id(data_service.get_id())
dg_conf = DatagridConf(namespace=namespace, name=name)
dg = DataGrid(self, conf=dg_conf, save_state=True)
dg.init_from_dataframe(df)
dg = DataGrid(self, conf=dg_conf, save_state=True, _id=grid_id)
self._registry.put(namespace, name, dg.get_id())
return dg
def _create_document(self, namespace: str, name: str, datagrid: DataGrid, tab_id: str = None) -> tuple[
str, DocumentDefinition]:
"""
Create a DocumentDefinition and its associated tab.
"""Create a DocumentDefinition and its associated tab.
Args:
namespace: Document namespace
name: Document name
datagrid: Associated DataGrid instance
tab_id: Optional existing tab ID. If None, creates a new tab
namespace: Document namespace.
name: Document name.
datagrid: Associated DataGrid instance.
tab_id: Optional existing tab ID. If None, creates a new tab.
Returns:
Tuple of (tab_id, document)
Tuple of (tab_id, document).
"""
if tab_id is None:
tab_id = self._tabs_manager.create_tab(name, datagrid)
@@ -159,15 +156,14 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
return tab_id, document
def _add_document_to_tree(self, document: DocumentDefinition, parent_id: str) -> TreeNode:
"""
Add a document to the tree view.
"""Add a document node to the tree view.
Args:
document: Document to add
parent_id: Parent node ID in the tree
document: Document to add.
parent_id: Parent node ID in the tree.
Returns:
Created TreeNode
Created TreeNode.
"""
tree_node = TreeNode(
id=document.document_id,
@@ -179,8 +175,17 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
self._tree.add_node(tree_node, parent_id=parent_id)
return tree_node
def new_grid(self):
# Determine parent folder
# ------------------------------------------------------------------
# Commands handlers
# ------------------------------------------------------------------
def upload_from_source(self):
file_upload = FileUpload(self)
tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload)
file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload))
return self._tabs_manager.show_tab(tab_id)
def handle_new_grid(self):
selected_id = self._tree.get_selected_id()
if selected_id is None:
parent_id = self._tree.ensure_path("Untitled")
@@ -188,52 +193,40 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
node = self._tree._state.items[selected_id]
if node.type == "folder":
parent_id = selected_id
else: # leaf
else:
parent_id = node.parent
# Get namespace and generate unique name
namespace = self._tree._state.items[parent_id].label
namespace = self._tree.get_state().items[parent_id].label
name = self._generate_unique_sheet_name(parent_id)
# Create and register DataGrid
dg = self._create_and_register_grid(namespace, name, pd.DataFrame())
# Create document and tab
tab_id, document = self._create_document(namespace, name, dg)
# Add to tree
self._add_document_to_tree(document, parent_id)
# UI-specific handling: open parent, select node, start rename
if parent_id not in self._tree._state.opened:
self._tree._state.opened.append(parent_id)
self._tree._state.selected = document.document_id
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)
return self._tree, self._tabs_manager.show_tab(tab_id)
def _generate_unique_sheet_name(self, parent_id: str) -> str:
children = self._tree._state.items[parent_id].children
existing_labels = {self._tree._state.items[c].label for c in children}
children = self._tree.get_state().items[parent_id].children
existing_labels = {self._tree.get_state().items[c].label for c in children}
n = 1
while f"Sheet{n}" in existing_labels:
n += 1
return f"Sheet{n}"
def open_from_excel(self, tab_id, file_upload: FileUpload):
# Read Excel data
excel_content = file_upload.get_content()
df = pd.read_excel(BytesIO(excel_content), file_upload.get_sheet_name())
namespace = file_upload.get_file_basename()
name = file_upload.get_sheet_name()
# Create and register DataGrid
dg = self._create_and_register_grid(namespace, name, df)
# Create document with existing tab
tab_id, document = self._create_document(namespace, name, dg, tab_id=tab_id)
# Add to tree
parent_id = self._tree.ensure_path(document.namespace)
self._add_document_to_tree(document, parent_id)
@@ -243,80 +236,61 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
document_id = self._tree.get_bag(node_id)
try:
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
dg = DataGrid(self, _id=document.datagrid_id) # reload the state & settings
dg = DataGrid(self, _id=document.datagrid_id)
return self._tabs_manager.show_or_create_tab(document.tab_id, document.name, dg)
except StopIteration:
# the selected node is not a document (it's a folder)
return None
def delete_grid(self, node_id):
"""
Delete a grid and all its associated resources.
"""Delete a grid and all its associated resources.
This method is called BEFORE TreeView._delete_node() to ensure we can
access the node's bag to retrieve the document_id.
Called BEFORE TreeView._delete_node() so we can access the node bag.
Args:
node_id: ID of the TreeView node to delete
node_id: ID of the TreeView node to delete.
Returns:
None (TreeView will handle the node removal)
List of UI updates, or None.
"""
document_id = self._tree.get_bag(node_id)
if document_id is None:
# Node is a folder, not a document - nothing to clean up
return None
res = []
try:
# Find the document
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
# Get the DataGrid instance
dg = DataGrid(self, _id=document.datagrid_id)
# Close the tab
close_tab_res = self._tabs_manager.close_tab(document.tab_id)
res.append(close_tab_res)
# Remove from registry
self._registry.remove(document.datagrid_id)
self._data_services_manager.remove_service(document.datagrid_id)
# Clean up DataGrid (delete DBEngine entries)
dg.delete()
# Remove from InstancesManager
InstancesManager.remove(self._session, document.datagrid_id)
# Remove DocumentDefinition from state
self._state.elements = [d for d in self._state.elements if d.document_id != document_id]
self._state.save()
except StopIteration:
# Document not found - already deleted or invalid state
pass
return res
def create_tab_content(self, tab_id):
"""
Recreate the content for a tab managed by this DataGridsManager.
Called by TabsManager when the content is not in cache (e.g., after restart).
"""Recreate tab content after restart.
Args:
tab_id: ID of the tab to recreate content for
tab_id: ID of the tab to recreate.
Returns:
The recreated component (Panel with DataGrid)
DataGrid instance for the tab.
"""
# Find the document associated with this tab
document = next((d for d in self._state.elements if d.tab_id == tab_id), None)
if document is None:
raise ValueError(f"No document found for tab {tab_id}")
# Recreate the DataGrid with its saved state
dg = DataGrid(self, _id=document.datagrid_id) # reload the state & settings
dg = DataGrid(self, _id=document.datagrid_id)
return dg
def clear_tree(self):
@@ -324,106 +298,22 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
self._tree.clear()
return self._tree
# === DatagridMetadataProvider ===
def list_tables(self):
return self._registry.get_all_tables()
def list_columns(self, table_name):
return self._registry.get_columns(table_name)
def list_column_values(self, table_name, column_name):
return self._registry.get_column_values(table_name, column_name)
def get_row_count(self, table_name):
return self._registry.get_row_count(table_name)
def get_column_type(self, table_name, column_name):
return self._registry.get_column_type(table_name, column_name)
def list_style_presets(self) -> list[str]:
return list(self.style_presets.keys())
def list_format_presets(self) -> list[str]:
return list(self.formatter_presets.keys())
def _resolve_store_for_table(self, table_name: str):
"""
Resolve the DatagridStore for a given table name.
Used by FormulaEngine as the registry_resolver callback.
Args:
table_name: Full table name in ``"namespace.name"`` format.
Returns:
DatagridStore instance or None if not found.
"""
try:
as_fullname_dict = self._registry._get_entries_as_full_name_dict()
grid_id = as_fullname_dict.get(table_name)
if grid_id is None:
return None
datagrid = InstancesManager.get(self._session, grid_id, None)
if datagrid is None:
return None
return datagrid._df_store
except Exception:
return None
def get_style_presets(self) -> dict:
"""Get the global style presets."""
return self.style_presets
def get_formatter_presets(self) -> dict:
"""Get the global formatter presets."""
return self.formatter_presets
def get_formula_engine(self) -> FormulaEngine:
"""The FormulaEngine shared across all DataGrids in this session."""
return self._formula_engine
def add_style_preset(self, name: str, preset: dict):
"""
Add or update a style preset.
Args:
name: Preset name (e.g., "custom_highlight")
preset: Dict with CSS properties (e.g., {"background-color": "yellow", "color": "black"})
"""
self.style_presets[name] = preset
def add_formatter_preset(self, name: str, preset: dict):
"""
Add or update a formatter preset.
Args:
name: Preset name (e.g., "custom_currency")
preset: Dict with formatter config (e.g., {"type": "number", "prefix": "CHF ", "precision": 2})
"""
self.formatter_presets[name] = preset
def remove_style_preset(self, name: str):
"""Remove a style preset."""
if name in self.style_presets:
del self.style_presets[name]
def remove_formatter_preset(self, name: str):
"""Remove a formatter preset."""
if name in self.formatter_presets:
del self.formatter_presets[name]
# === UI ===
# ------------------------------------------------------------------
# UI
# ------------------------------------------------------------------
def mk_main_icons(self):
return Div(
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
mk.icon(table_add20_regular, tooltip="New grid", command=self.commands.new_grid()),
mk.icon(folder_open20_regular, tooltip="Upload from source",
command=self.commands.upload_from_source()),
mk.icon(table_add20_regular, tooltip="New grid",
command=self.commands.new_grid()),
cls="flex"
)
def _mk_tree(self):
conf = TreeViewConf(add_leaf=False, icons={"folder": "database20_regular", "excel": "table20_regular"})
conf = TreeViewConf(add_leaf=False,
icons={"folder": "database20_regular", "excel": "table20_regular"})
tree = TreeView(self, conf=conf, _id="-treeview")
for element in self._state.elements:
parent_id = tree.ensure_path(element.namespace, node_type="folder")

View File

@@ -293,6 +293,9 @@ class TreeView(MultipleInstance):
return self._state.items[node_id].bag
except KeyError:
return None
def get_state(self) -> TreeViewState:
return self._state
def _toggle_node(self, node_id: str):
"""Toggle expand/collapse state of a node."""

View File

@@ -1,10 +1,11 @@
from dataclasses import dataclass, field
from myfasthtml.core.constants import ColumnType, DATAGRID_DEFAULT_COLUMN_WIDTH, ViewType
from myfasthtml.core.constants import DATAGRID_DEFAULT_COLUMN_WIDTH, ViewType, ROW_SELECTION_ID, ColumnType
from myfasthtml.core.data.ColumnDefinition import ColumnDefinition
@dataclass
class DataGridRowState:
class DataGridRowUiState:
row_id: int
visible: bool = True
height: int | None = None
@@ -12,19 +13,32 @@ class DataGridRowState:
@dataclass
class DataGridColumnState:
col_id: str # name of the column: cannot be changed
col_index: int # index of the column in the dataframe: cannot be changed
title: str = None
type: ColumnType = ColumnType.Text
class DataGridColumnUiState:
"""UI presentation state of a DataGrid column.
Holds only the visual properties of a column. Keyed by col_id to match
the corresponding ColumnDefinition held by DataService.
Attributes:
col_id: Column identifier. Must match the col_id in ColumnDefinition.
width: Column width in pixels.
visible: Whether the column is displayed.
format: List of format rules applied during rendering.
"""
col_id: str
visible: bool = True
width: int = DATAGRID_DEFAULT_COLUMN_WIDTH
format: list = field(default_factory=list) #
formula: str = "" # formula expression for ColumnType.Formula columns
format: list = field(default_factory=list)
def copy(self):
props = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
return DataGridColumnState(**props)
def copy(self) -> "DataGridColumnUiState":
"""Return a shallow copy of this state."""
return DataGridColumnUiState(
col_id=self.col_id,
width=self.width,
visible=self.visible,
format=list(self.format),
)
@dataclass
@@ -56,4 +70,71 @@ class DataGridHeaderFooterConf:
class DatagridView:
name: str
type: ViewType = ViewType.Table
columns: list[DataGridColumnState] = None
columns: list[DataGridColumnUiState] = None
class DataGridColumnState:
def __init__(self, col_def: ColumnDefinition, col_ui_state: DataGridColumnUiState):
self._col_def = col_def
self._col_ui_state = col_ui_state
@property
def col_id(self):
return self._col_def.col_id
@property
def col_index(self):
return self._col_def.col_index
@property
def title(self):
return self._col_def.title
@property
def type(self):
return self._col_def.type
@property
def visible(self):
return self._col_ui_state.visible
@property
def width(self):
return self._col_ui_state.width
@property
def format(self):
return self._col_ui_state.format
class DataGridRowSelectionColumnState(DataGridColumnState):
def __init__(self):
super().__init__(None, None)
@property
def col_id(self):
return ROW_SELECTION_ID
@property
def col_index(self):
return -1
@property
def title(self):
return ""
@property
def type(self):
return ColumnType.RowSelection_
@property
def visible(self):
return True # not used
@property
def width(self):
return 24 # not used
@property
def format(self):
return ""

View File

@@ -37,75 +37,10 @@ class DataGridsRegistry(SingleInstance):
del all_entries[datagrid_id]
self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, all_entries)
def get_all_tables(self):
all_entries = self._get_all_entries()
return [f"{namespace}.{name}" for (namespace, name) in all_entries.values()]
def get_columns(self, table_name):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
# load datagrid state
state_id = f"{grid_id}#state"
state = self._db_manager.load(state_id)
return [c.col_id for c in state["columns"]] if state else []
except KeyError:
return []
def get_column_values(self, table_name, column_name):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
# load dataframe from dedicated store
store = self._db_manager.load(f"{grid_id}#df")
df = store["ne_df"] if store else None
return df[column_name].tolist() if df is not None else []
except KeyError:
return []
def get_row_count(self, table_name):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
# load dataframe from dedicated store
store = self._db_manager.load(f"{grid_id}#df")
df = store["ne_df"] if store else None
return len(df) if df is not None else 0
except KeyError:
return 0
def get_column_type(self, table_name, column_name):
"""
Get the type of a column.
def get_all_entries(self) -> dict:
"""Return all registry entries as {datagrid_id: (namespace, name)}."""
return self._get_all_entries()
Args:
table_name: The DataGrid name
column_name: The column name
Returns:
ColumnType enum value or None if not found
"""
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
# load datagrid state
state_id = f"{grid_id}#state"
state = self._db_manager.load(state_id)
if state and "columns" in state:
for col in state["columns"]:
if col.col_id == column_name:
return col.type
return None
except KeyError:
return None
def _get_all_entries(self):
return {k: v for k, v in self._db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY).items()
if not k.startswith("__")}

View File

@@ -82,6 +82,7 @@ class DataService(MultipleInstance):
super().__init__(parent, _id=_id)
self._state = DataServiceState(self, save_state=save_state)
self._store = DataStore(self, save_state=save_state)
self._init_store()
@property
def columns(self) -> list[ColumnDefinition]:
@@ -128,12 +129,7 @@ class DataService(MultipleInstance):
self._state.columns = self._build_column_definitions(df)
self._state.save()
self._store.ns_fast_access = self._build_fast_access(df)
self._store.ns_row_data = df.to_dict(orient="records")
self._store.ns_total_rows = len(df)
self._store.save()
self._register_existing_formulas()
self._init_store()
# ------------------------------------------------------------------
# Mutations
@@ -302,6 +298,16 @@ class DataService(MultipleInstance):
# Private helpers
# ------------------------------------------------------------------
def _init_store(self):
df = self._store.ne_df
if df is None:
return
self._store.ns_fast_access = self._build_fast_access(df)
self._store.ns_row_data = df.to_dict(orient="records")
self._store.ns_total_rows = len(df)
self._store.save()
self._register_existing_formulas()
def _build_column_definitions(self, df: pd.DataFrame) -> list[ColumnDefinition]:
"""Build ColumnDefinition objects from DataFrame columns.

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"