From 70915b2691397c194ed985a1210f372c4487bc3f Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Mon, 16 Feb 2026 21:57:39 +0100 Subject: [PATCH] I can add new columns --- src/myfasthtml/assets/datagrid/datagrid.js | 1 - src/myfasthtml/controls/DataGrid.py | 162 +++++++++++------- .../controls/DataGridColumnsManager.py | 2 + src/myfasthtml/controls/helpers.py | 13 +- src/myfasthtml/core/utils.py | 12 ++ .../controls/test_datagrid_columns_manager.py | 3 +- tests/core/test_utils.py | 27 ++- 7 files changed, 142 insertions(+), 78 deletions(-) diff --git a/src/myfasthtml/assets/datagrid/datagrid.js b/src/myfasthtml/assets/datagrid/datagrid.js index dcff29b..b762149 100644 --- a/src/myfasthtml/assets/datagrid/datagrid.js +++ b/src/myfasthtml/assets/datagrid/datagrid.js @@ -712,7 +712,6 @@ function updateDatagridSelection(datagridId) { }); } - /** * Find the parent element with .dt2-cell class and return its id. * Used with hx-vals="js:getCellId()" for DataGrid cell identification. diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index fe7c4c0..5f58889 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -21,7 +21,7 @@ from myfasthtml.controls.Mouse import Mouse from myfasthtml.controls.Panel import Panel, PanelConf from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState -from myfasthtml.controls.helpers import mk, icons +from myfasthtml.controls.helpers import mk, icons, column_type_defaults from myfasthtml.core.commands import Command from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID from myfasthtml.core.dbmanager import DbObject @@ -32,7 +32,7 @@ from myfasthtml.core.formatting.dsl.parser import DSLParser from myfasthtml.core.formatting.engine import FormattingEngine from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.optimized_ft import OptimizedDiv -from myfasthtml.core.utils import make_safe_id, merge_classes +from myfasthtml.core.utils import make_safe_id, merge_classes, make_unique_safe_id 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 @@ -342,6 +342,74 @@ 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. + + Priority (most specific wins): + 1. Cell-level: self._state.cell_formats[cell_id] + 2. Row-level: row_state.format (if row has specific state) + 3. Column-level: col_def.format + 4. Table-level: self._state.table_format + 5. Tables-level (global): manager.all_tables_formats + + Args: + col_pos: Column position index + row_index: Row index + col_def: DataGridColumnState for the column + + Returns: + list[FormatRule] or None if no formatting defined + """ + + cell_id = self._get_element_id_from_pos("cell", (col_pos, row_index)) + + if cell_id in self._state.cell_formats: + return self._state.cell_formats[cell_id] + + row_state = next((r for r in self._state.rows if r.row_id == row_index), None) + if row_state and row_state.format: + return row_state.format + + if col_def.format: + return col_def.format + + if self._state.table_format: + return self._state.table_format + + # Get global tables formatting from manager + return self._parent.all_tables_formats + def init_from_dataframe(self, df, init_state=True): def _get_column_type(dtype): @@ -422,73 +490,36 @@ class DataGrid(MultipleInstance): return self - def _register_existing_formulas(self) -> None: - """ - Re-register all formula columns with the FormulaEngine. + def add_new_column(self, col_def: DataGridColumnState) -> None: + """Add a new column to the DataGrid. - 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. + For Formula columns, only _state.columns is updated; the FormulaEngine + computes values on demand via recalculate_if_needed(). - 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. - - Priority (most specific wins): - 1. Cell-level: self._state.cell_formats[cell_id] - 2. Row-level: row_state.format (if row has specific state) - 3. Column-level: col_def.format - 4. Table-level: self._state.table_format - 5. Tables-level (global): manager.all_tables_formats + 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. Args: - col_pos: Column position index - row_index: Row index - col_def: DataGridColumnState for the column - - Returns: - list[FormatRule] or None if no formatting defined + col_def: Column definition with title and type already set. + col_id is derived from title via make_safe_id. """ + col_def.col_id = make_unique_safe_id(col_def.title, [c.col_id for c in self._state.columns]) - cell_id = self._get_element_id_from_pos("cell", (col_pos, row_index)) + if col_def.type == ColumnType.Formula: + col_def.col_index = -1 + self._state.columns.append(col_def) + return - if cell_id in self._state.cell_formats: - return self._state.cell_formats[cell_id] + 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) - row_state = next((r for r in self._state.rows if r.row_id == row_index), None) - if row_state and row_state.format: - return row_state.format - - if col_def.format: - return col_def.format - - if self._state.table_format: - return self._state.table_format - - # Get global tables formatting from manager - return self._parent.all_tables_formats + 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 set_column_width(self, col_id: str, width: str): """Update column width after resize. Called via Command from JS.""" @@ -609,7 +640,6 @@ class DataGrid(MultipleInstance): def get_formula_engine(self): """Return the FormulaEngine from the DataGridsManager, if available.""" return self._parent.get_formula_engine() - def mk_headers(self): resize_cmd = self.commands.set_column_width() @@ -676,7 +706,7 @@ class DataGrid(MultipleInstance): return Span(*res, cls=f"{css_class} truncate", style=style) if len(res) > 1 else res[0] column_type = col_def.type - + # 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: @@ -696,7 +726,7 @@ class DataGrid(MultipleInstance): column_type = ColumnType.Text else: value = self._df_store.ns_fast_access[col_def.col_id][row_index] - + # Boolean type - uses cached HTML (only 2 possible values) if column_type == ColumnType.Bool: return _mk_bool_cached(value) @@ -740,7 +770,7 @@ class DataGrid(MultipleInstance): """ if not col_def.visible: return None - + col_array = self._df_store.ns_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) @@ -969,7 +999,7 @@ class DataGrid(MultipleInstance): res = [] extra_attr = { - "hx-on::after-settle": f"initDataGridScrollbars('{self._id}');", + "hx-on::after-settle": f"initDataGrid('{self._id}');", } if fragment == "body": diff --git a/src/myfasthtml/controls/DataGridColumnsManager.py b/src/myfasthtml/controls/DataGridColumnsManager.py index 347f030..fb85a1c 100644 --- a/src/myfasthtml/controls/DataGridColumnsManager.py +++ b/src/myfasthtml/controls/DataGridColumnsManager.py @@ -134,6 +134,8 @@ class DataGridColumnsManager(MultipleInstance): def save_column_details(self, col_id, client_response): logger.debug(f"save_column_details {col_id=}, {client_response=}") col_def = self._get_updated_col_def_from_col_id(col_id, client_response, copy=False) + if col_def.col_id == "__new__": + self._parent.add_new_column(col_def) # sets the correct col_id before _register_formula self._register_formula(col_def) self._parent.save_state() diff --git a/src/myfasthtml/controls/helpers.py b/src/myfasthtml/controls/helpers.py index dcd2e3d..aaf151c 100644 --- a/src/myfasthtml/controls/helpers.py +++ b/src/myfasthtml/controls/helpers.py @@ -1,3 +1,4 @@ +import pandas as pd from fasthtml.components import * from myfasthtml.core.bindings import Binding @@ -151,9 +152,9 @@ icons = { None: question20_regular, True: checkbox_checked20_regular, False: checkbox_unchecked20_regular, - + "Brain": brain_circuit20_regular, - + ColumnType.RowIndex: number_symbol20_regular, ColumnType.Text: text_field20_regular, ColumnType.Number: number_row20_regular, @@ -162,3 +163,11 @@ icons = { ColumnType.Enum: text_bullet_list_square20_regular, ColumnType.Formula: math_formula16_regular, } + +column_type_defaults = { + ColumnType.Number: 0, + ColumnType.Text: "", + ColumnType.Bool: False, + ColumnType.Datetime: pd.NaT, +} + \ No newline at end of file diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index e01a117..b194124 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -323,6 +323,18 @@ def make_safe_id(s: str | None): return res.lower() # no uppercase +def make_unique_safe_id(s: str | None, existing_ids: list): + if s is None: + return None + + base = make_safe_id(s) + res = base + i = 1 + while res in existing_ids: + res = f"{base}_{i}" + i += 1 + return res + def get_class(qualified_class_name: str): """ Dynamically loads and returns a class type from its fully qualified name. diff --git a/tests/controls/test_datagrid_columns_manager.py b/tests/controls/test_datagrid_columns_manager.py index f8f84af..e206f48 100644 --- a/tests/controls/test_datagrid_columns_manager.py +++ b/tests/controls/test_datagrid_columns_manager.py @@ -36,7 +36,7 @@ class MockDataGrid(MultipleInstance): def get_table_name(self): return "mock_table" - + def get_formula_engine(self): return None @@ -163,7 +163,6 @@ class TestDataGridColumnsManagerBehaviour: 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" diff --git a/tests/core/test_utils.py b/tests/core/test_utils.py index bce1699..89eda5c 100644 --- a/tests/core/test_utils.py +++ b/tests/core/test_utils.py @@ -1,6 +1,6 @@ import pytest -from myfasthtml.core.utils import flatten, make_html_id, pascal_to_snake, snake_to_pascal +from myfasthtml.core.utils import flatten, make_html_id, pascal_to_snake, snake_to_pascal, make_unique_safe_id @pytest.mark.parametrize("input_args,expected,test_description", [ @@ -57,6 +57,7 @@ def test_i_can_flatten(input_args, expected, test_description): result = flatten(*input_args) assert result == expected, f"Failed for test case: {test_description}" + @pytest.mark.parametrize("string, expected", [ ("My Example String!", "My-Example-String_"), ("123 Bad ID", "id_123-Bad-ID"), @@ -82,9 +83,9 @@ def test_i_can_have_valid_html_id(string, expected): (" ", "", "only spaces"), ]) def test_i_can_convert_pascal_to_snake(input_str, expected, test_description): - """Test that pascal_to_snake correctly converts PascalCase/camelCase to snake_case.""" - result = pascal_to_snake(input_str) - assert result == expected, f"Failed for test case: {test_description}" + """Test that pascal_to_snake correctly converts PascalCase/camelCase to snake_case.""" + result = pascal_to_snake(input_str) + assert result == expected, f"Failed for test case: {test_description}" @pytest.mark.parametrize("input_str, expected, test_description", [ @@ -104,6 +105,18 @@ def test_i_can_convert_pascal_to_snake(input_str, expected, test_description): ("___", "", "only underscores"), ]) def test_i_can_convert_snake_to_pascal(input_str, expected, test_description): - """Test that snake_to_pascal correctly converts snake_case to PascalCase.""" - result = snake_to_pascal(input_str) - assert result == expected, f"Failed for test case: {test_description}" + """Test that snake_to_pascal correctly converts snake_case to PascalCase.""" + result = snake_to_pascal(input_str) + assert result == expected, f"Failed for test case: {test_description}" + + +@pytest.mark.parametrize("name, existing_ids, expected", [ + (None, [], None), + ("MyClass", [], "myclass"), + ("MyClass", ["myclass"], "myclass_1"), + ("MyClass", ["myclass", "myclass_1"], "myclass_2"), +]) +def test_make_unique_safe_id(name, existing_ids, expected): + assert make_unique_safe_id(name, existing_ids) == expected, ( + f"Failed for name: {name}, existing_ids: {existing_ids}, expected: {expected}" + )