I can add new columns
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user