I can add new columns

This commit is contained in:
2026-02-16 21:57:39 +01:00
parent f3e19743c8
commit 70915b2691
7 changed files with 142 additions and 78 deletions

View File

@@ -712,7 +712,6 @@ function updateDatagridSelection(datagridId) {
}); });
} }
/** /**
* Find the parent element with .dt2-cell class and return its id. * Find the parent element with .dt2-cell class and return its id.
* Used with hx-vals="js:getCellId()" for DataGrid cell identification. * Used with hx-vals="js:getCellId()" for DataGrid cell identification.

View File

@@ -21,7 +21,7 @@ from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.Panel import Panel, PanelConf from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \ from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState 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.commands import Command
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
from myfasthtml.core.dbmanager import DbObject 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.formatting.engine import FormattingEngine
from myfasthtml.core.instances import MultipleInstance from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.optimized_ft import OptimizedDiv 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.carbon import row, column, grid
from myfasthtml.icons.fluent import checkbox_unchecked16_regular from myfasthtml.icons.fluent import checkbox_unchecked16_regular
from myfasthtml.icons.fluent_p2 import checkbox_checked16_regular, column_edit20_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.selection.selected = pos
self._state.save() 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 init_from_dataframe(self, df, init_state=True):
def _get_column_type(dtype): def _get_column_type(dtype):
@@ -422,73 +490,36 @@ class DataGrid(MultipleInstance):
return self return self
def _register_existing_formulas(self) -> None: def add_new_column(self, col_def: DataGridColumnState) -> None:
""" """Add a new column to the DataGrid.
Re-register all formula columns with the FormulaEngine.
Called after data reload to ensure the engine knows about all For Formula columns, only _state.columns is updated; the FormulaEngine
formula columns and their expressions. computes values on demand via recalculate_if_needed().
"""
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: 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.
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: Args:
col_pos: Column position index col_def: Column definition with title and type already set.
row_index: Row index col_id is derived from title via make_safe_id.
col_def: DataGridColumnState for the column
Returns:
list[FormatRule] or None if no formatting defined
""" """
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: default_value = column_type_defaults.get(col_def.type, "")
return self._state.cell_formats[cell_id] 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 self._df is not None:
if row_state and row_state.format: self._df_store.ne_df[col_def.col_id] = default_value
return row_state.format 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:
if col_def.format: row_dict[col_def.col_id] = default_value
return col_def.format self._df_store.save()
if self._state.table_format:
return self._state.table_format
# Get global tables formatting from manager
return self._parent.all_tables_formats
def set_column_width(self, col_id: str, width: str): def set_column_width(self, col_id: str, width: str):
"""Update column width after resize. Called via Command from JS.""" """Update column width after resize. Called via Command from JS."""
@@ -610,7 +641,6 @@ class DataGrid(MultipleInstance):
"""Return the FormulaEngine from the DataGridsManager, if available.""" """Return the FormulaEngine from the DataGridsManager, if available."""
return self._parent.get_formula_engine() return self._parent.get_formula_engine()
def mk_headers(self): def mk_headers(self):
resize_cmd = self.commands.set_column_width() resize_cmd = self.commands.set_column_width()
move_cmd = self.commands.move_column() move_cmd = self.commands.move_column()
@@ -969,7 +999,7 @@ class DataGrid(MultipleInstance):
res = [] res = []
extra_attr = { extra_attr = {
"hx-on::after-settle": f"initDataGridScrollbars('{self._id}');", "hx-on::after-settle": f"initDataGrid('{self._id}');",
} }
if fragment == "body": if fragment == "body":

View File

@@ -134,6 +134,8 @@ class DataGridColumnsManager(MultipleInstance):
def save_column_details(self, col_id, client_response): def save_column_details(self, col_id, client_response):
logger.debug(f"save_column_details {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) 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._register_formula(col_def)
self._parent.save_state() self._parent.save_state()

View File

@@ -1,3 +1,4 @@
import pandas as pd
from fasthtml.components import * from fasthtml.components import *
from myfasthtml.core.bindings import Binding from myfasthtml.core.bindings import Binding
@@ -162,3 +163,11 @@ icons = {
ColumnType.Enum: text_bullet_list_square20_regular, ColumnType.Enum: text_bullet_list_square20_regular,
ColumnType.Formula: math_formula16_regular, ColumnType.Formula: math_formula16_regular,
} }
column_type_defaults = {
ColumnType.Number: 0,
ColumnType.Text: "",
ColumnType.Bool: False,
ColumnType.Datetime: pd.NaT,
}

View File

@@ -323,6 +323,18 @@ def make_safe_id(s: str | None):
return res.lower() # no uppercase 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): def get_class(qualified_class_name: str):
""" """
Dynamically loads and returns a class type from its fully qualified name. Dynamically loads and returns a class type from its fully qualified name.

View File

@@ -163,7 +163,6 @@ class TestDataGridColumnsManagerBehaviour:
def test_i_can_update_column_title(self, columns_manager): def test_i_can_update_column_title(self, columns_manager):
"""Test updating a column's title via client_response.""" """Test updating a column's title via client_response."""
columns_manager.save_column_details("name", {"title": "New Name"}) columns_manager.save_column_details("name", {"title": "New Name"})
col_def = columns_manager._get_col_def_from_col_id("name") col_def = columns_manager._get_col_def_from_col_id("name")
assert col_def.title == "New Name" assert col_def.title == "New Name"

View File

@@ -1,6 +1,6 @@
import pytest 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", [ @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) result = flatten(*input_args)
assert result == expected, f"Failed for test case: {test_description}" assert result == expected, f"Failed for test case: {test_description}"
@pytest.mark.parametrize("string, expected", [ @pytest.mark.parametrize("string, expected", [
("My Example String!", "My-Example-String_"), ("My Example String!", "My-Example-String_"),
("123 Bad ID", "id_123-Bad-ID"), ("123 Bad ID", "id_123-Bad-ID"),
@@ -107,3 +108,15 @@ def test_i_can_convert_snake_to_pascal(input_str, expected, test_description):
"""Test that snake_to_pascal correctly converts snake_case to PascalCase.""" """Test that snake_to_pascal correctly converts snake_case to PascalCase."""
result = snake_to_pascal(input_str) result = snake_to_pascal(input_str)
assert result == expected, f"Failed for test case: {test_description}" 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}"
)