diff --git a/src/app.py b/src/app.py index 3f5b370..9a6d2fd 100644 --- a/src/app.py +++ b/src/app.py @@ -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")) diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index f85f617..b61a03b 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -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('') 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;" diff --git a/src/myfasthtml/controls/DataGridColumnsManager.py b/src/myfasthtml/controls/DataGridColumnsManager.py index fdf63f8..cc2ad87 100644 --- a/src/myfasthtml/controls/DataGridColumnsManager.py +++ b/src/myfasthtml/controls/DataGridColumnsManager.py @@ -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( diff --git a/src/myfasthtml/controls/DataGridFormattingEditor.py b/src/myfasthtml/controls/DataGridFormattingEditor.py index 659fc68..7245861 100644 --- a/src/myfasthtml/controls/DataGridFormattingEditor.py +++ b/src/myfasthtml/controls/DataGridFormattingEditor.py @@ -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 diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index 19d41fc..cc535ac 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -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") diff --git a/src/myfasthtml/controls/TreeView.py b/src/myfasthtml/controls/TreeView.py index 8e03024..ba15c04 100644 --- a/src/myfasthtml/controls/TreeView.py +++ b/src/myfasthtml/controls/TreeView.py @@ -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.""" diff --git a/src/myfasthtml/controls/datagrid_objects.py b/src/myfasthtml/controls/datagrid_objects.py index 2cb7c79..d050f86 100644 --- a/src/myfasthtml/controls/datagrid_objects.py +++ b/src/myfasthtml/controls/datagrid_objects.py @@ -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 "" diff --git a/src/myfasthtml/core/DataGridsRegistry.py b/src/myfasthtml/core/DataGridsRegistry.py index 5c199b3..8b6838a 100644 --- a/src/myfasthtml/core/DataGridsRegistry.py +++ b/src/myfasthtml/core/DataGridsRegistry.py @@ -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("__")} diff --git a/src/myfasthtml/core/data/DataService.py b/src/myfasthtml/core/data/DataService.py index 27c4562..c3fa5e5 100644 --- a/src/myfasthtml/core/data/DataService.py +++ b/src/myfasthtml/core/data/DataService.py @@ -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. diff --git a/tests/controls/test_datagrid.py b/tests/controls/test_datagrid.py new file mode 100644 index 0000000..414ccf8 --- /dev/null +++ b/tests/controls/test_datagrid.py @@ -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}'" + ) diff --git a/tests/controls/test_datagrid_formatting.py b/tests/controls/test_datagrid_formatting.py index c782b81..b80bb79 100644 --- a/tests/controls/test_datagrid_formatting.py +++ b/tests/controls/test_datagrid_formatting.py @@ -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 diff --git a/tests/controls/test_datagridmanager.py b/tests/controls/test_datagridsmanager.py similarity index 60% rename from tests/controls/test_datagridmanager.py rename to tests/controls/test_datagridsmanager.py index 0422390..602b838 100644 --- a/tests/controls/test_datagridmanager.py +++ b/tests/controls/test_datagridsmanager.py @@ -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"