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}"
+ )