From e704dad62c81e8c0cbfadabdfed84129b0ae5072 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Wed, 11 Mar 2026 20:59:07 +0100 Subject: [PATCH] Fixed unit tests --- src/app.py | 2 +- src/myfasthtml/controls/DataGrid.py | 70 ++- .../controls/DataGridColumnsList.py | 19 +- .../controls/DataGridFormattingEditor.py | 2 +- src/myfasthtml/controls/DataGridsManager.py | 13 +- src/myfasthtml/controls/datagrid_objects.py | 6 + src/myfasthtml/core/data/DataService.py | 3 + .../core/data/DataServicesManager.py | 7 + .../completion/FormattingCompletionEngine.py | 6 +- .../formatting/dsl/completion/provider.py | 329 +++++++------- tests/controls/conftest.py | 29 ++ tests/controls/test_datagrid.py | 26 ++ .../controls/test_datagrid_columns_manager.py | 423 ------------------ tests/controls/test_datagrid_formatting.py | 93 ++-- tests/controls/test_icons_helper.py | 4 +- 15 files changed, 353 insertions(+), 679 deletions(-) delete mode 100644 tests/controls/test_datagrid_columns_manager.py diff --git a/src/app.py b/src/app.py index 9a6d2fd..c43144b 100644 --- a/src/app.py +++ b/src/app.py @@ -78,7 +78,7 @@ def index(session): layout.left_drawer.add(btn_popup, "Test") # data grids - dgs_manager = DataGridsManager(session_instance) + dgs_manager = DataGridsManager(session_instance, save_state=True) 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 137dc57..377f16f 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -224,6 +224,10 @@ class DataGrid(MultipleInstance): self._columns = None self.commands = Commands(self) + # reset + self._state.selection.selected = None + self._state.selection.last_selected = None + # 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) @@ -264,7 +268,7 @@ class DataGrid(MultipleInstance): # self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed()) if self._settings.enable_formatting: - provider = InstancesManager.get_by_type(self._session, DatagridMetadataProvider, None) + provider = DatagridMetadataProvider(self._parent) completion_engine = FormattingCompletionEngine(provider, self.get_table_name()) editor_conf = DslEditorConf(engine_id=completion_engine.get_id()) dsl = FormattingDSL() @@ -305,6 +309,10 @@ class DataGrid(MultipleInstance): def _fast_access(self): return self._data_service.get_store().ns_fast_access + @property + def _row_data(self): + return self._data_service.get_store().ns_row_data + def _apply_sort(self, df): if df is None: return None @@ -413,9 +421,9 @@ class DataGrid(MultipleInstance): if self._state.table_format: return self._state.table_format - # 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 [] + # Get global tables formatting from DataGridsManager + dgm = self.get_parent() + return dgm.get_state().all_tables_formats if dgm is not None else [] def _init_columns(self): # Populate UI state from DataService columns when creating a new grid @@ -494,22 +502,6 @@ class DataGrid(MultipleInstance): self._state.save() - def handle_reorder_columns(self, order: list[str]): - """Reorder columns based on a full ordered list of column IDs. - - Args: - order: List of col_id strings in the desired display order. - Columns not present in order are appended at the end. - """ - logger.debug(f"handle_reorder_columns: {order=}") - columns_by_id = {c.col_id: c for c in self._state.columns} - new_order = [columns_by_id[col_id] for col_id in order if col_id in columns_by_id] - remaining = [c for c in self._state.columns if c.col_id not in order] - self._state.columns = new_order + remaining - self._init_columns() - self._state.save() - return self.render_partial("table") - def calculate_optimal_column_width(self, col_id: str) -> int: """ Calculate optimal width for a column based on content. @@ -625,6 +617,42 @@ class DataGrid(MultipleInstance): self._state.save() return self.render_partial() + def handle_columns_reorder(self, order: list[str]): + """Reorder columns based on a full ordered list of column IDs. + + Args: + order: List of col_id strings in the desired display order. + Columns not present in order are appended at the end. + """ + logger.debug(f"handle_reorder_columns: {order=}") + columns_by_id = {c.col_id: c for c in self._state.columns} + new_order = [columns_by_id[col_id] for col_id in order if col_id in columns_by_id] + remaining = [c for c in self._state.columns if c.col_id not in order] + self._state.columns = new_order + remaining + self._init_columns() + self._state.save() + return self.render_partial("table") + + def handle_columns_updates(self, updates: dict[str, dict]): + logger.debug(f"handle_columns_update: {updates=}") + + def _update(col_def): + need_saving = False + for key, value in update.items(): + if hasattr(col_def, key): + setattr(col_def, key, value) + need_saving = True + return need_saving + + for col_id, update in updates.items(): + column_state = [col_def for col_def in self._columns if col_def.col_id == col_id][0] + if _update(column_state.get_col_ui_state()): + self._state.save() + if _update(column_state.get_col_def()): + self._data_service.save_state() + + return self.render_partial("table") + def handle_toggle_columns_manager(self): logger.debug(f"toggle_columns_manager") self._panel.set_title(side="right", title="Columns") @@ -803,7 +831,7 @@ class DataGrid(MultipleInstance): formatted_value = None rules = self._get_format_rules(col_pos, row_index, col_def) if rules: - row_data = self._df_store.ns_row_data[row_index] if row_index < len(self._df_store.ns_row_data) else None + row_data = self._row_data[row_index] if row_index < len(self._row_data) else None style, formatted_value = self._formatting_engine.apply_format(rules, value, row_data) # Use formatted value or convert to string diff --git a/src/myfasthtml/controls/DataGridColumnsList.py b/src/myfasthtml/controls/DataGridColumnsList.py index b821b4a..dfbc7ee 100644 --- a/src/myfasthtml/controls/DataGridColumnsList.py +++ b/src/myfasthtml/controls/DataGridColumnsList.py @@ -23,6 +23,14 @@ class Commands(BaseCommands): self._owner, self._owner.handle_on_reorder ).htmx(target=f"#{self._id}") + + def toggle_column_visibility(self, col_id: str): + return Command("ToggleColumnVisibility", + "Toggle column visibility", + self._owner, + self._owner.handle_toggle_column_visibility, + kwargs={"col_id": col_id} + ).htmx(target=f"#{self._id}") class DataGridColumnsList(MultipleInstance): @@ -43,7 +51,14 @@ class DataGridColumnsList(MultipleInstance): def handle_on_reorder(self, order: list): logger.debug(f"on_reorder {order=}") - ret = self._parent.handle_reorder_columns(order) + ret = self._parent.handle_columns_reorder(order) + return self.render(), ret + + def handle_toggle_column_visibility(self, col_id): + logger.debug(f"handle_toggle_column_visibility {col_id=}") + col_def = [c for c in self.columns if c.col_id == col_id][0] + updates = {col_id: {"visible": not col_def.visible}} + ret = self._parent.handle_columns_updates(updates) return self.render(), ret def mk_column_label(self, col_def: DataGridColumnState): @@ -51,7 +66,7 @@ class DataGridColumnsList(MultipleInstance): mk.icon(grip_horizontal, cls="mf-drag-handle cursor-grab mr-1 opacity-40"), mk.mk( Input(type="checkbox", cls="checkbox checkbox-sm", checked=col_def.visible), - # command=self.commands.toggle_column(col_def.col_id) + command=self.commands.toggle_column_visibility(col_def.col_id) ), mk.mk( Div( diff --git a/src/myfasthtml/controls/DataGridFormattingEditor.py b/src/myfasthtml/controls/DataGridFormattingEditor.py index 7245861..0e97be2 100644 --- a/src/myfasthtml/controls/DataGridFormattingEditor.py +++ b/src/myfasthtml/controls/DataGridFormattingEditor.py @@ -127,7 +127,7 @@ class DataGridFormattingEditor(DslEditor): from myfasthtml.controls.DataGridsManager import DataGridsManager manager = InstancesManager.get_by_type(self._session, DataGridsManager) if manager: - manager.all_tables_formats = tables_rules + manager.get_state().all_tables_formats = tables_rules # Step 6: Update state atomically self._parent.get_state().update(state) diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index cc535ac..91848f7 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -15,6 +15,7 @@ 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.dataclasses import FormatRule 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 @@ -31,10 +32,11 @@ class DocumentDefinition: class DataGridsState(DbObject): - def __init__(self, owner, name=None): - super().__init__(owner, name=name) + def __init__(self, owner, save_state, name=None): + super().__init__(owner, save_state=save_state, name=name) with self.initializing(): self.elements: list[DocumentDefinition] = [] + self.all_tables_formats: list[FormatRule] = [] class Commands(BaseCommands): @@ -87,12 +89,12 @@ class DataGridsManager(SingleInstance): by DataServicesManager and DataService. """ - def __init__(self, parent, _id=None): + def __init__(self, parent, _id=None, save_state=None): if not getattr(self, "_is_new_instance", False): return super().__init__(parent, _id=_id) self.commands = Commands(self) - self._state = DataGridsState(self) + self._state = DataGridsState(self, save_state) self._tree = self._mk_tree() self._tree.bind_command("SelectNode", self.commands.show_document()) self._tree.bind_command("DeleteNode", self.commands.delete_grid(), when="before") @@ -102,6 +104,9 @@ class DataGridsManager(SingleInstance): # Data layer — session-scoped singletons self._data_services_manager = DataServicesManager(self._parent) + def get_state(self): + return self._state + # ------------------------------------------------------------------ # Grid lifecycle # ------------------------------------------------------------------ diff --git a/src/myfasthtml/controls/datagrid_objects.py b/src/myfasthtml/controls/datagrid_objects.py index d050f86..590da76 100644 --- a/src/myfasthtml/controls/datagrid_objects.py +++ b/src/myfasthtml/controls/datagrid_objects.py @@ -78,6 +78,12 @@ class DataGridColumnState: self._col_def = col_def self._col_ui_state = col_ui_state + def get_col_def(self): + return self._col_def + + def get_col_ui_state(self): + return self._col_ui_state + @property def col_id(self): return self._col_def.col_id diff --git a/src/myfasthtml/core/data/DataService.py b/src/myfasthtml/core/data/DataService.py index c3fa5e5..0f1d904 100644 --- a/src/myfasthtml/core/data/DataService.py +++ b/src/myfasthtml/core/data/DataService.py @@ -84,6 +84,9 @@ class DataService(MultipleInstance): self._store = DataStore(self, save_state=save_state) self._init_store() + def save_state(self): + self._state.save() + @property def columns(self) -> list[ColumnDefinition]: """Return the list of column definitions.""" diff --git a/src/myfasthtml/core/data/DataServicesManager.py b/src/myfasthtml/core/data/DataServicesManager.py index e59a9c4..34bd472 100644 --- a/src/myfasthtml/core/data/DataServicesManager.py +++ b/src/myfasthtml/core/data/DataServicesManager.py @@ -120,3 +120,10 @@ class DataServicesManager(SingleInstance): return service.get_store() logger.warning(f"DataServicesManager: table '{table_name}' not found") return None + + def clear(self): + """ + For test purposes only. + :return: + """ + self._services.clear() \ No newline at end of file diff --git a/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py b/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py index 9582d88..ebfec55 100644 --- a/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py +++ b/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py @@ -3,6 +3,7 @@ Completion engine for the formatting DSL. Implements the BaseCompletionEngine for DataGrid formatting rules. """ +import logging from myfasthtml.core.dsl.base_completion import BaseCompletionEngine from myfasthtml.core.dsl.types import Position, Suggestion, CompletionResult @@ -11,6 +12,7 @@ from . import presets from .contexts import Context, DetectedScope, detect_scope, detect_context from .provider import DatagridMetadataProvider +logger = logging.getLogger("FormattingCompletionEngine") class FormattingCompletionEngine(BaseCompletionEngine): """ @@ -222,8 +224,8 @@ class FormattingCompletionEngine(BaseCompletionEngine): if tables: columns = self.provider.list_columns(self.table_name) return [Suggestion(col, "Column", "column") for col in columns] - except Exception: - pass + except Exception as ex: + logger.error(f"Error getting column suggestions: {ex}") return [] def _get_column_suggestions_with_closing_quote(self) -> list[Suggestion]: diff --git a/src/myfasthtml/core/formatting/dsl/completion/provider.py b/src/myfasthtml/core/formatting/dsl/completion/provider.py index 2d436d3..ede2118 100644 --- a/src/myfasthtml/core/formatting/dsl/completion/provider.py +++ b/src/myfasthtml/core/formatting/dsl/completion/provider.py @@ -18,186 +18,185 @@ logger = logging.getLogger(__name__) class DatagridMetadataProvider(SingleInstance, BaseMetadataProvider): - """Concrete session-scoped metadata provider for DataGrid DSL engines. + """Concrete session-scoped metadata provider for DataGrid DSL engines. - Implements BaseMetadataProvider by delegating live data queries to - DataServicesManager. Also holds the global formatting presets and the - all_tables_formats rule applied to every table. + Implements BaseMetadataProvider by delegating live data queries to + DataServicesManager. Also holds the global formatting presets and the + all_tables_formats rule applied to every table. - Access pattern (from any component): - provider = InstancesManager.get_by_type(session, DatagridMetadataProvider) + Access pattern (from any component): + provider = InstancesManager.get_by_type(session, DatagridMetadataProvider) - Attributes: - style_presets: Dict of named style presets available in the DSL. - formatter_presets: Dict of named formatter presets available in the DSL. - all_tables_formats: Global format rules applied to all tables. + Attributes: + style_presets: Dict of named style presets available in the DSL. + formatter_presets: Dict of named formatter presets available in the DSL. + all_tables_formats: Global format rules applied to all tables. + """ + + def __init__(self, parent=None, session: Optional[dict] = None, + _id: Optional[str] = None): + super().__init__(parent, session, _id) + self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy() + self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy() + self.all_tables_formats: list = [] + + # ------------------------------------------------------------------ + # Table and column metadata — delegated to DataServicesManager + # ------------------------------------------------------------------ + + def list_tables(self) -> list[str]: + """Return the list of all registered table names. + + Returns: + List of table names in "namespace.name" format. """ + manager = self._get_data_services_manager() + if manager is None: + return [] + return [s.table_name for s in manager._services.values() if s.table_name] + + def list_columns(self, table_name: str) -> list[str]: + """Return the column identifiers for a table. - def __init__(self, parent=None, session: Optional[dict] = None, - _id: Optional[str] = None): - super().__init__(parent, session, _id) - with self.initializing(): - self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy() - self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy() - self.all_tables_formats: list = [] + Args: + table_name: Fully qualified table name. - # ------------------------------------------------------------------ - # Table and column metadata — delegated to DataServicesManager - # ------------------------------------------------------------------ + Returns: + List of col_id strings. + """ + service = self._get_service(table_name) + if service is None: + return [] + return [c.col_id for c in service.columns] + + def list_column_values(self, table_name: str, column_name: str) -> list[Any]: + """Return the distinct values present in a column. - def list_tables(self) -> list[str]: - """Return the list of all registered table names. + Args: + table_name: Fully qualified table name. + column_name: Column identifier. - Returns: - List of table names in "namespace.name" format. - """ - manager = self._get_data_services_manager() - if manager is None: - return [] - return [s.table_name for s in manager._services.values() if s.table_name] + Returns: + List of distinct values, empty list if not found. + """ + service = self._get_service(table_name) + if service is None: + return [] + store = service.get_store() + if store.ne_df is None or column_name not in store.ne_df.columns: + return [] + return store.ne_df[column_name].dropna().unique().tolist() + + def get_row_count(self, table_name: str) -> int: + """Return the number of rows in a table. - def list_columns(self, table_name: str) -> list[str]: - """Return the column identifiers for a table. + Args: + table_name: Fully qualified table name. - Args: - table_name: Fully qualified table name. + Returns: + Row count, or 0 if not found. + """ + service = self._get_service(table_name) + if service is None: + return 0 + store = service.get_store() + return store.ns_total_rows or 0 + + def get_column_type(self, table_name: str, column_name: str): + """Return the ColumnType for a column. - Returns: - List of col_id strings. - """ - service = self._get_service(table_name) - if service is None: - return [] - return [c.col_id for c in service.columns] + Args: + table_name: Fully qualified table name. + column_name: Column identifier. - def list_column_values(self, table_name: str, column_name: str) -> list[Any]: - """Return the distinct values present in a column. + Returns: + ColumnType enum value, or None if not found. + """ + service = self._get_service(table_name) + if service is None: + return None + for col in service.columns: + if col.col_id == column_name: + return col.type + return None + + # ------------------------------------------------------------------ + # Preset metadata — held locally + # ------------------------------------------------------------------ + + def list_style_presets(self) -> list[str]: + """Return the names of all registered style presets.""" + return list(self.style_presets.keys()) + + def list_format_presets(self) -> list[str]: + """Return the names of all registered formatter presets.""" + return list(self.formatter_presets.keys()) + + def get_style_presets(self) -> dict: + """Return the full style presets dict.""" + return self.style_presets + + def get_formatter_presets(self) -> dict: + """Return the full formatter presets dict.""" + return self.formatter_presets + + def add_style_preset(self, name: str, preset: dict) -> None: + """Add or update a named style preset. - Args: - table_name: Fully qualified table name. - column_name: Column identifier. + Args: + name: Preset name. + preset: Style definition dict. + """ + self.style_presets[name] = preset + + def add_formatter_preset(self, name: str, preset: dict) -> None: + """Add or update a named formatter preset. - Returns: - List of distinct values, empty list if not found. - """ - service = self._get_service(table_name) - if service is None: - return [] - store = service.get_store() - if store.ne_df is None or column_name not in store.ne_df.columns: - return [] - return store.ne_df[column_name].dropna().unique().tolist() + Args: + name: Preset name. + preset: Formatter definition dict. + """ + self.formatter_presets[name] = preset + + def remove_style_preset(self, name: str) -> None: + """Remove a style preset by name. - def get_row_count(self, table_name: str) -> int: - """Return the number of rows in a table. + Args: + name: Preset name to remove. + """ + self.style_presets.pop(name, None) + + def remove_formatter_preset(self, name: str) -> None: + """Remove a formatter preset by name. - Args: - table_name: Fully qualified table name. + Args: + name: Preset name to remove. + """ + self.formatter_presets.pop(name, None) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _get_data_services_manager(self) -> Optional[DataServicesManager]: + """Return the DataServicesManager for this session.""" + return InstancesManager.get_by_type( + self._session, DataServicesManager, default=None + ) + + def _get_service(self, table_name: str): + """Return the DataService for a given table name. - Returns: - Row count, or 0 if not found. - """ - service = self._get_service(table_name) - if service is None: - return 0 - store = service.get_store() - return store.ns_total_rows or 0 + Args: + table_name: Fully qualified table name. - def get_column_type(self, table_name: str, column_name: str): - """Return the ColumnType for a column. - - Args: - table_name: Fully qualified table name. - column_name: Column identifier. - - Returns: - ColumnType enum value, or None if not found. - """ - service = self._get_service(table_name) - if service is None: - return None - for col in service.columns: - if col.col_id == column_name: - return col.type - return None - - # ------------------------------------------------------------------ - # Preset metadata — held locally - # ------------------------------------------------------------------ - - def list_style_presets(self) -> list[str]: - """Return the names of all registered style presets.""" - return list(self.style_presets.keys()) - - def list_format_presets(self) -> list[str]: - """Return the names of all registered formatter presets.""" - return list(self.formatter_presets.keys()) - - def get_style_presets(self) -> dict: - """Return the full style presets dict.""" - return self.style_presets - - def get_formatter_presets(self) -> dict: - """Return the full formatter presets dict.""" - return self.formatter_presets - - def add_style_preset(self, name: str, preset: dict) -> None: - """Add or update a named style preset. - - Args: - name: Preset name. - preset: Style definition dict. - """ - self.style_presets[name] = preset - - def add_formatter_preset(self, name: str, preset: dict) -> None: - """Add or update a named formatter preset. - - Args: - name: Preset name. - preset: Formatter definition dict. - """ - self.formatter_presets[name] = preset - - def remove_style_preset(self, name: str) -> None: - """Remove a style preset by name. - - Args: - name: Preset name to remove. - """ - self.style_presets.pop(name, None) - - def remove_formatter_preset(self, name: str) -> None: - """Remove a formatter preset by name. - - Args: - name: Preset name to remove. - """ - self.formatter_presets.pop(name, None) - - # ------------------------------------------------------------------ - # Private helpers - # ------------------------------------------------------------------ - - def _get_data_services_manager(self) -> Optional[DataServicesManager]: - """Return the DataServicesManager for this session.""" - return InstancesManager.get_by_type( - self._session, DataServicesManager, default=None - ) - - def _get_service(self, table_name: str): - """Return the DataService for a given table name. - - Args: - table_name: Fully qualified table name. - - Returns: - DataService instance, or None if not found. - """ - manager = self._get_data_services_manager() - if manager is None: - return None - for service in manager._services.values(): - if service.table_name == table_name: - return service - return None + Returns: + DataService instance, or None if not found. + """ + manager = self._get_data_services_manager() + if manager is None: + return None + for service in manager._services.values(): + if service.table_name == table_name: + return service + return None diff --git a/tests/controls/conftest.py b/tests/controls/conftest.py index 0acbb37..8895b9f 100644 --- a/tests/controls/conftest.py +++ b/tests/controls/conftest.py @@ -1,5 +1,10 @@ import pytest +from pandas import DataFrame +from myfasthtml.controls.DataGrid import DataGrid, DatagridConf +from myfasthtml.controls.DataGridsManager import DataGridsManager +from myfasthtml.controls.TabsManager import TabsManager +from myfasthtml.core.data.DataServicesManager import DataServicesManager from myfasthtml.core.instances import SingleInstance, InstancesManager @@ -27,3 +32,27 @@ def session(): def root_instance(session): InstancesManager.reset() return RootInstanceForTests(session=session) + + +@pytest.fixture +def datagrids_manager(root_instance): + return DataGridsManager(root_instance) + + +@pytest.fixture +def dataservices_manager(root_instance): + return InstancesManager.get_by_type(root_instance._session, DataServicesManager) + + +def get_data_grid(root_instance, df: DataFrame, table_name: str = "test.grid1", save_state: bool = False): + TabsManager(root_instance) # just define it + dgm = DataGridsManager(root_instance) + dsm = InstancesManager.get_by_type(root_instance._session, DataServicesManager) + + data_service = dsm.create_service(table_name, save_state=save_state) + data_service.load_dataframe(df) + + grid_id = DataGrid.get_grid_id_from_data_service_id(data_service.get_id()) + namespace, table_name = table_name.split(".") + conf = DatagridConf(namespace=namespace, name=table_name) + return DataGrid(dgm, conf=conf, save_state=save_state, _id=grid_id) diff --git a/tests/controls/test_datagrid.py b/tests/controls/test_datagrid.py index 29922f9..e9a1917 100644 --- a/tests/controls/test_datagrid.py +++ b/tests/controls/test_datagrid.py @@ -3,6 +3,7 @@ import pytest from fastcore.basics import NotStr from fasthtml.components import Div, Script +from controls.conftest import get_data_grid from myfasthtml.controls.DataGrid import DataGrid, DatagridConf from myfasthtml.controls.DataGridsManager import DataGridsManager from myfasthtml.controls.TabsManager import TabsManager @@ -357,6 +358,31 @@ class TestDataGridBehaviour: dg._selection_mode_selector.cycle_state() dg.change_selection_mode() assert dg._state.selection.selection_mode == "cell" + + def test_i_can_handle_column_update(self, root_instance): + df = pd.DataFrame({ + "name": ["Alice", "Bob", "Charlie"], + "age": [25, 30, 35], + "active": [True, False, True], + }) + dg = get_data_grid(root_instance, df, save_state=True) + + dg.handle_columns_updates({"name": {"title": "Name", + "width": 150, + "formula": "{age} + 1", + "type": ColumnType.Number, + "visible": False}}) + + # check the ui_state + col_ui_state = dg._state.columns[0] + assert col_ui_state.width == 150 + assert col_ui_state.visible is False + + # check the colum def + col_def = dg._data_service._state.columns[0] + assert col_def.title == "Name" + assert col_def.type == ColumnType.Number + assert col_def.formula == "{age} + 1" class TestDataGridRender: diff --git a/tests/controls/test_datagrid_columns_manager.py b/tests/controls/test_datagrid_columns_manager.py deleted file mode 100644 index 56c2202..0000000 --- a/tests/controls/test_datagrid_columns_manager.py +++ /dev/null @@ -1,423 +0,0 @@ -import shutil -from dataclasses import dataclass, field - -import pytest -from fasthtml.common import Div, FT, Input, Form, Fieldset, Select - -from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager -from myfasthtml.controls.Search import Search -from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridColumnUiState -from myfasthtml.core.constants import ColumnType -from myfasthtml.core.data.ColumnDefinition import ColumnDefinition -from myfasthtml.core.instances import InstancesManager, MultipleInstance -from myfasthtml.test.matcher import ( - matches, find_one, find, Contains, TestIcon, TestObject -) - - -@dataclass -class MockDatagridState: - """Mock state object that mimics DatagridState.""" - columns: list = field(default_factory=list) - - -class MockDataGrid(MultipleInstance): - """Mock DataGrid parent for testing DataGridColumnsManager.""" - - def __init__(self, parent, columns=None, _id=None): - super().__init__(parent, _id=_id) - self._state = MockDatagridState(columns=columns or []) - self._save_state_called = False - - def get_state(self): - return self._state - - def save_state(self): - self._save_state_called = True - - def get_table_name(self): - return "mock_table" - - def get_formula_engine(self): - return None - - -# col_def: ColumnDefinition, col_ui_state: DataGridColumnUiState - -@pytest.fixture -def mock_datagrid(root_instance): - """Create a mock DataGrid with sample columns.""" - columns = [ - DataGridColumnState(ColumnDefinition(col_id="name", col_index=0, title="Name", type=ColumnType.Text), - DataGridColumnUiState(col_id="name", visible=True)), - DataGridColumnState(ColumnDefinition(col_id="age", col_index=1, title="Age", type=ColumnType.Number), - DataGridColumnUiState(col_id="age", visible=True)), - DataGridColumnState(ColumnDefinition(col_id="email", col_index=2, title="Email", type=ColumnType.Text), - DataGridColumnUiState(col_id="email", visible=False)), - ] - yield MockDataGrid(root_instance, columns=columns, _id="test-datagrid") - InstancesManager.reset() - - -@pytest.fixture -def columns_manager(mock_datagrid): - """Create a DataGridColumnsManager instance for testing.""" - shutil.rmtree(".myFastHtmlDb", ignore_errors=True) - yield DataGridColumnsManager(mock_datagrid) - shutil.rmtree(".myFastHtmlDb", ignore_errors=True) - - -class TestDataGridColumnsManagerBehaviour: - """Tests for DataGridColumnsManager behavior and logic.""" - - # ========================================================================= - # Get Column Definition - # ========================================================================= - - def test_i_can_get_existing_column_by_id(self, columns_manager): - """Test finding an existing column by its ID.""" - col_def = columns_manager._get_col_def_from_col_id("name") - - assert col_def is not None - assert col_def.col_id == "name" - assert col_def.title == "Name" - - def test_i_cannot_get_nonexistent_column(self, columns_manager): - """Test that getting a nonexistent column returns None.""" - col_def = columns_manager._get_col_def_from_col_id("nonexistent") - - assert col_def is None - - def test_i_can_get_and_update_column_state(self, columns_manager): - """Test that get_col_def_from_col_id updates the column state.""" - updates = {"title": "New Name", "visible": "on", "type": "Number", "width": 200} - col_def = columns_manager._get_updated_col_def_from_col_id("name", updates) - assert col_def.title == "New Name" - assert col_def.visible is True - assert col_def.type == ColumnType.Number - assert col_def.width == 200 - - def test_i_can_get_and_update_column_state_visible_false(self, columns_manager): - """Test that get_col_def_from_col_id updates the column state.""" - updates = {} # visible is missing in the update => It must be set to False - col_def = columns_manager._get_updated_col_def_from_col_id("name", updates) - - assert col_def.visible is False - - # ========================================================================= - # Toggle Column Visibility - # ========================================================================= - - @pytest.mark.parametrize("col_id, initial_visible, expected_visible", [ - ("name", True, False), # visible -> hidden - ("email", False, True), # hidden -> visible - ]) - def test_i_can_toggle_column_visibility(self, columns_manager, col_id, initial_visible, expected_visible): - """Test toggling column visibility from visible to hidden and vice versa.""" - col_def = columns_manager._get_col_def_from_col_id(col_id) - assert col_def.visible == initial_visible - - columns_manager.toggle_column(col_id) - - col_def = columns_manager._get_col_def_from_col_id(col_id) - assert col_def.visible == expected_visible - - def test_toggle_column_saves_state(self, columns_manager, mock_datagrid): - """Test that toggle_column calls save_state on parent.""" - mock_datagrid._save_state_called = False - - columns_manager.toggle_column("name") - - assert mock_datagrid._save_state_called is True - - def test_toggle_column_returns_column_label(self, columns_manager): - """Test that toggle_column returns an HTML element.""" - result = columns_manager.toggle_column("name") - - assert isinstance(result, FT) - - def test_i_cannot_toggle_nonexistent_column(self, columns_manager): - """Test that toggling a nonexistent column returns an error message.""" - result = columns_manager.toggle_column("nonexistent") - - expected = Div("Column 'nonexistent' not found") - assert matches(result, expected) - - # ========================================================================= - # Show All Columns - # ========================================================================= - - def test_show_all_columns_returns_search_component(self, columns_manager): - """Test that mk_all_columns returns a Search component.""" - result = columns_manager.mk_all_columns() - - assert isinstance(result, Search) - - def test_show_all_columns_returns_configured_search(self, columns_manager): - """Test that mk_all_columns returns a correctly configured Search component.""" - result = columns_manager.mk_all_columns() - - assert result.items_names == "Columns" - assert len(result.items) == 3 - col_def = result.items[0] - assert result.get_attr(col_def) == col_def.col_id - - # ========================================================================= - # Update Column - # ========================================================================= - - def test_i_can_update_column_title(self, columns_manager): - """Test updating a column's title via client_response.""" - columns_manager.save_column_details("name", {"title": "New Name"}) - col_def = columns_manager._get_col_def_from_col_id("name") - assert col_def.title == "New Name" - - def test_i_can_update_column_visibility_via_form(self, columns_manager): - """Test updating column visibility via checkbox form value.""" - col_def = columns_manager._get_col_def_from_col_id("name") - assert col_def.visible is True - - # Unchecked checkbox sends nothing, checked sends "on" - columns_manager.save_column_details("name", {"visible": "off"}) # Not "on" means unchecked - col_def = columns_manager._get_col_def_from_col_id("name") - assert col_def.visible is False - - # Check it back on - columns_manager.save_column_details("name", {"visible": "on"}) - col_def = columns_manager._get_col_def_from_col_id("name") - assert col_def.visible is True - - def test_i_can_update_column_type(self, columns_manager): - """Test updating a column's type.""" - columns_manager.save_column_details("name", {"type": "Number"}) - - col_def = columns_manager._get_col_def_from_col_id("name") - assert col_def.type == ColumnType.Number - - def test_i_can_update_column_width(self, columns_manager): - """Test updating a column's width.""" - columns_manager.save_column_details("name", {"width": "200"}) - - col_def = columns_manager._get_col_def_from_col_id("name") - assert col_def.width == 200 - - def test_update_column_saves_state(self, columns_manager, mock_datagrid): - """Test that save_column_details calls save_state on parent.""" - mock_datagrid._save_state_called = False - - columns_manager.save_column_details("name", {"title": "Updated"}) - - assert mock_datagrid._save_state_called is True - - def test_update_column_ignores_unknown_attributes(self, columns_manager): - """Test that save_column_details ignores attributes not in DataGridColumnState.""" - columns_manager.save_column_details("name", {"unknown_attr": "value", "title": "New Title"}) - - col_def = columns_manager._get_col_def_from_col_id("name") - assert col_def.title == "New Title" - assert not hasattr(col_def, "unknown_attr") - - -class TestDataGridColumnsManagerRender: - """Tests for DataGridColumnsManager HTML rendering.""" - - @pytest.fixture - def columns_manager(self, mock_datagrid): - """Create a fresh DataGridColumnsManager for render tests.""" - shutil.rmtree(".myFastHtmlDb", ignore_errors=True) - cm = DataGridColumnsManager(mock_datagrid) - yield cm - shutil.rmtree(".myFastHtmlDb", ignore_errors=True) - - # ========================================================================= - # Global Structure - # ========================================================================= - - def test_i_can_render_columns_manager_with_columns(self, columns_manager): - """Test that DataGridColumnsManager renders with correct global structure. - - Why these elements matter: - - id: Required for HTMX targeting in commands - - Contains Search component: Main content for column list - """ - html = columns_manager.render() - - expected = Div( - TestObject(Search), # Search component (column list) - Div(), # New column button - id=columns_manager._id, - ) - - assert matches(html, expected) - - # ========================================================================= - # mk_column_label - # ========================================================================= - - def test_column_label_has_checkbox_and_details_navigation(self, columns_manager): - """Test that column label contains checkbox and navigation to details. - - Why these elements matter: - - Checkbox (Input type=checkbox): Controls column visibility - - Label with column ID: Identifies the column - - Chevron icon: Indicates navigation to details - - id with tcolman_ prefix: Required for HTMX swap targeting - """ - col_def = columns_manager._get_col_def_from_col_id("name") - label = columns_manager.mk_column_label(col_def) - - # Should have the correct ID pattern - expected = Div( - id=f"tcolman_{columns_manager._id}-name", - cls=Contains("flex"), - ) - assert matches(label, expected) - - # Should contain a checkbox - checkbox = find_one(label, Input(type="checkbox")) - assert checkbox is not None - - # Should contain chevron icon for navigation - chevron = find_one(label, TestIcon("chevron_right20_regular")) - assert chevron is not None - - def test_column_label_checkbox_is_checked_when_visible(self, columns_manager): - """Test that checkbox is checked when column is visible. - - Why this matters: - - checked attribute: Reflects current visibility state - - User can see which columns are visible - """ - col_def = columns_manager._get_col_def_from_col_id("name") - assert col_def.visible is True - - label = columns_manager.mk_column_label(col_def) - checkbox = find_one(label, Input(type="checkbox")) - - # Checkbox should have checked attribute - assert checkbox.attrs.get("checked") is True - - def test_column_label_checkbox_is_unchecked_when_hidden(self, columns_manager): - """Test that checkbox is unchecked when column is hidden. - - Why this matters: - - No checked attribute: Indicates column is hidden - - Visual feedback for user - """ - col_def = columns_manager._get_col_def_from_col_id("email") - assert col_def.visible is False - - label = columns_manager.mk_column_label(col_def) - checkbox = find_one(label, Input(type="checkbox")) - - # Checkbox should not have checked attribute (or it should be False/None) - checked = checkbox.attrs.get("checked") - assert checked is None or checked is False - - # ========================================================================= - # mk_column_details - # ========================================================================= - - def test_column_details_contains_all_form_fields(self, columns_manager): - """Test that column details form contains all required fields. - - Why these elements matter: - - col_id field (readonly): Shows column identifier - - title field: Editable column display name - - visible checkbox: Toggle visibility - - type select: Change column type - - width input: Set column width - """ - col_def = columns_manager._get_col_def_from_col_id("name") - details = columns_manager.mk_column_details(col_def) - - # Should contain Form - form = Form() - del form.attrs["enctype"] - form = find_one(details, form) - assert form is not None - - # Should contain all required input fields - col_id_input = find_one(form, Input(name="col_id")) - assert col_id_input is not None - assert col_id_input.attrs.get("readonly") is True - - title_input = find_one(form, Input(name="title")) - assert title_input is not None - - visible_checkbox = find_one(form, Input(name="visible", type="checkbox")) - assert visible_checkbox is not None - - type_select = find_one(form, Select(name="type")) - assert type_select is not None - - width_input = find_one(form, Input(name="width", type="number")) - assert width_input is not None - - def test_column_details_has_back_button(self, columns_manager): - """Test that column details has a back button to return to all columns. - - Why this matters: - - Back navigation: User can return to column list - - Chevron left icon: Visual indicator of back action - """ - col_def = columns_manager._get_col_def_from_col_id("name") - details = columns_manager.mk_column_details(col_def) - - # Should contain back chevron icon - back_icon = find_one(details, TestIcon("chevron_left20_regular", wrapper="span")) - assert back_icon is not None - - def test_column_details_form_has_fieldset_with_legend(self, columns_manager): - """Test that column details form has a fieldset with legend. - - Why this matters: - - Fieldset groups related fields - - Legend provides context ("Column details") - """ - col_def = columns_manager._get_col_def_from_col_id("name") - details = columns_manager.mk_column_details(col_def) - - fieldset = find_one(details, Fieldset(legend="Column details")) - assert fieldset is not None - - # ========================================================================= - # show_column_details - # ========================================================================= - - def test_i_can_show_column_details_for_existing_column(self, columns_manager): - """Test that show_column_details returns a form-based view for an existing column. - - Why these elements matter: - - Form element: show_column_details must return an editable form view - - Exactly one form: Ensures the response is unambiguous (not multiple forms) - """ - result = columns_manager.show_column_details("name") - - expected = Form() - del expected.attrs["enctype"] - forms = find(result, expected) - assert len(forms) == 1, "Should contain exactly one form" - - # ========================================================================= - # mk_all_columns - # ========================================================================= - - def test_all_columns_renders_all_column_labels(self, columns_manager): - """Test that all columns render produces labels for all columns. - - Why this matters: - - All columns visible in list - - Each column has its label rendered - """ - search = columns_manager.mk_all_columns() - rendered = search.render() - - # Should find 3 column labels in the results - results_div = find_one(rendered, Div(id=f"{search._id}-results")) - assert results_div is not None - - # Each column should have a label with tcolman_ prefix - for col_id in ["name", "age", "email"]: - label = find_one(results_div, Div(id=f"tcolman_{columns_manager._id}-{col_id}")) - assert label is not None, f"Column label for '{col_id}' should be present" diff --git a/tests/controls/test_datagrid_formatting.py b/tests/controls/test_datagrid_formatting.py index 3a283a5..4cbd34f 100644 --- a/tests/controls/test_datagrid_formatting.py +++ b/tests/controls/test_datagrid_formatting.py @@ -5,44 +5,39 @@ Tests the complete formatting flow: DSL → Storage → Application. """ import pytest +from pandas import DataFrame -from myfasthtml.controls.DataGrid import DataGrid +from controls.conftest import get_data_grid from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor -from myfasthtml.controls.DataGridsManager import DataGridsManager -from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridColumnUiState -from myfasthtml.core.constants import ColumnType -from myfasthtml.core.data.ColumnDefinition import ColumnDefinition +from myfasthtml.controls.datagrid_objects import DataGridRowUiState +from myfasthtml.core.data.DataServicesManager import DataServicesManager from myfasthtml.core.formatting.dataclasses import FormatRule, Style from myfasthtml.core.formatting.dsl.definition import FormattingDSL from myfasthtml.core.instances import InstancesManager @pytest.fixture -def manager(root_instance): - """Create a DataGridsManager instance.""" - mgr = DataGridsManager(root_instance, _id="test-manager") - yield mgr - InstancesManager.reset() +def datagrid(root_instance): + """Create a DataGrid instance.""" + df = DataFrame( + {"amount": [100, 200, 300], + "status": ["active", "inactive", "active"]} + ) + dg = get_data_grid(root_instance, df, table_name="app.products") + dg.handle_columns_updates({"amount": {"title": "Amount"}, + "status": {"title": "Status"}}) + yield dg + + dsm = InstancesManager.get_by_type(root_instance._session, DataServicesManager) + dsm.clear() + dgm = dg.get_parent() + dgm.get_state().all_tables_formats.clear() @pytest.fixture -def datagrid(manager): - """Create a DataGrid instance.""" - from myfasthtml.controls.DataGrid import DatagridConf - conf = DatagridConf(namespace="app", name="products") - grid = DataGrid(manager, conf=conf, save_state=False, _id="mf-data_grid-test-datagrid") - - # ColumnDefinition, col_ui_state: DataGridColumnUiState - # Add some columns - grid.columns = [ - DataGridColumnState(ColumnDefinition(col_id="amount", col_index=0, title="Amount", type=ColumnType.Number), - DataGridColumnUiState(col_id="amount", visible=True)), - DataGridColumnState(ColumnDefinition(col_id="status", col_index=1, title="Status", type=ColumnType.Text), - DataGridColumnUiState(col_id="status", visible=True)) - ] - - yield grid - InstancesManager.reset() +def datagrids_manager(datagrid): + """Create a DataGridsManager instance.""" + return datagrid.get_parent() @pytest.fixture @@ -65,7 +60,7 @@ class TestFormatRulesHierarchy: column_rules = [FormatRule(style=Style(preset="success"))] table_rules = [FormatRule(style=Style(preset="info"))] - datagrid._state.cell_formats["tcell_test-datagrid-0-0"] = cell_rules + datagrid._state.cell_formats[f"tcell_{datagrid.get_id()}-0-0"] = cell_rules datagrid._state.columns[0].format = column_rules datagrid._state.table_format = table_rules @@ -82,7 +77,8 @@ class TestFormatRulesHierarchy: column_rules = [FormatRule(style=Style(preset="success"))] table_rules = [FormatRule(style=Style(preset="info"))] - datagrid._state.rows[0].format = row_rules + datagrid._state.rows.append(DataGridRowUiState(1, format=table_rules)) # this one should be skipped + datagrid._state.rows.append(DataGridRowUiState(0, format=row_rules)) datagrid._state.columns[0].format = column_rules datagrid._state.table_format = table_rules @@ -107,14 +103,14 @@ class TestFormatRulesHierarchy: # Should return column rules assert rules == column_rules - def test_i_can_get_table_level_rules(self, datagrid, manager): + def test_i_can_get_table_level_rules(self, datagrid, datagrids_manager): """Test that table-level rules have fourth priority.""" # Setup rules at different levels table_rules = [FormatRule(style=Style(preset="info"))] tables_rules = [FormatRule(style=Style(preset="neutral"))] datagrid._state.table_format = table_rules - manager.all_tables_formats = tables_rules + datagrids_manager.get_state().all_tables_formats = tables_rules # Get rules for cell (no higher level rules) rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0]) @@ -122,11 +118,11 @@ class TestFormatRulesHierarchy: # Should return table rules assert rules == table_rules - def test_i_can_get_tables_level_rules(self, datagrid, manager): + def test_i_can_get_tables_level_rules(self, datagrid, datagrids_manager): """Test that tables-level rules have lowest priority.""" # Setup global rules tables_rules = [FormatRule(style=Style(preset="neutral"))] - manager.all_tables_formats = tables_rules + datagrids_manager.get_state().all_tables_formats = tables_rules # Get rules for cell (no other rules) rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0]) @@ -138,25 +134,6 @@ class TestFormatRulesHierarchy: """Test that None is returned when no rules are defined.""" rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0]) assert rules == [] - - @pytest.mark.parametrize("level,setup_func,expected_preset", [ - ("cell", lambda dg: dg._state.cell_formats.__setitem__("tcell_test-datagrid-0-0", - [FormatRule(style=Style(preset="error"))]), "error"), - ("row", lambda dg: setattr(dg._state.rows[0], "format", - [FormatRule(style=Style(preset="warning"))]), "warning"), - ("column", lambda dg: setattr(dg._state.columns[0], "format", - [FormatRule(style=Style(preset="success"))]), "success"), - ("table", lambda dg: setattr(dg._state, "table_format", - [FormatRule(style=Style(preset="info"))]), "info"), - ]) - def test_hierarchy_priority(self, datagrid, level, setup_func, expected_preset): - """Test that each level has correct priority in the hierarchy.""" - setup_func(datagrid) - rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0]) - - assert rules is not None - assert len(rules) == 1 - assert rules[0].style.preset == expected_preset # ============================================================================= @@ -194,7 +171,7 @@ table "wrong_name": # Rules should not be applied (wrong table name) assert len(datagrid._state.table_format) == 0 - def test_i_can_dispatch_tables_rules(self, manager, datagrid, editor): + def test_i_can_dispatch_tables_rules(self, datagrids_manager, datagrid, editor): """Test that tables rules are dispatched to DataGridsManager.""" dsl = ''' @@ -206,11 +183,11 @@ tables: editor.on_content_changed() # Check that manager.all_tables_formats is populated - assert len(manager.all_tables_formats) == 2 - assert manager.all_tables_formats[0].style.preset == "neutral" - assert manager.all_tables_formats[1].formatter.precision == 2 + assert len(datagrids_manager.get_state().all_tables_formats) == 2 + assert datagrids_manager.get_state().all_tables_formats[0].style.preset == "neutral" + assert datagrids_manager.get_state().all_tables_formats[1].formatter.precision == 2 - def test_i_can_combine_all_scope_types(self, manager, datagrid, editor): + def test_i_can_combine_all_scope_types(self, datagrids_manager, datagrid, editor): """Test that all 5 scope types can be used together.""" dsl = ''' @@ -233,7 +210,7 @@ cell (amount, 1): editor.on_content_changed() # Check all levels are populated - assert len(manager.all_tables_formats) == 1 + assert len(datagrids_manager.get_state().all_tables_formats) == 1 assert len(datagrid._state.table_format) == 1 assert len(datagrid._state.columns[0].format) == 1 assert len(datagrid._state.rows[0].format) == 1 diff --git a/tests/controls/test_icons_helper.py b/tests/controls/test_icons_helper.py index e2d2b1a..ed27aba 100644 --- a/tests/controls/test_icons_helper.py +++ b/tests/controls/test_icons_helper.py @@ -1,4 +1,5 @@ from myfasthtml.controls.IconsHelper import IconsHelper +from myfasthtml.test.matcher import matches, TestIconNotStr def test_existing_icon(): @@ -13,8 +14,7 @@ def test_dynamic_icon(): def test_unknown_icon(): IconsHelper.reset() - assert IconsHelper.get("does_not_exist") is None - + assert matches(IconsHelper.get("does_not_exist"), TestIconNotStr("Question20Regular")) def test_dynamic_icon_by_package():