Refactoring DataGrid to use DataService.py

This commit is contained in:
2026-03-02 22:34:14 +01:00
parent 0a766581ed
commit 30a77d1171
12 changed files with 1349 additions and 656 deletions

View File

@@ -1,9 +1,7 @@
import json
import logging.config
import pandas as pd
import yaml
from dbengine.handlers import BaseRefHandler, handlers
from dbengine.handlers import handlers
from fasthtml import serve
from fasthtml.components import Div
@@ -15,7 +13,6 @@ from myfasthtml.controls.InstancesDebugger import InstancesDebugger
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.controls.helpers import Ids, mk
from myfasthtml.core.dbengine_utils import DataFrameHandler
from myfasthtml.core.instances import UniqueInstance
@@ -39,53 +36,6 @@ app, rt = create_app(protect_routes=True,
base_url="http://localhost:5003")
def create_sample_treeview(parent):
"""
Create a sample TreeView with a file structure for testing.
Args:
parent: Parent instance for the TreeView
Returns:
TreeView: Configured TreeView instance with sample data
"""
tree_view = TreeView(parent, _id="-treeview")
# Create sample file structure
projects = TreeNode(label="Projects", type="folder")
tree_view.add_node(projects)
myfasthtml = TreeNode(label="MyFastHtml", type="folder")
tree_view.add_node(myfasthtml, parent_id=projects.id)
app_py = TreeNode(label="app.py", type="file")
tree_view.add_node(app_py, parent_id=myfasthtml.id)
readme = TreeNode(label="README.md", type="file")
tree_view.add_node(readme, parent_id=myfasthtml.id)
src_folder = TreeNode(label="src", type="folder")
tree_view.add_node(src_folder, parent_id=myfasthtml.id)
controls_py = TreeNode(label="controls.py", type="file")
tree_view.add_node(controls_py, parent_id=src_folder.id)
documents = TreeNode(label="Documents", type="folder")
tree_view.add_node(documents, parent_id=projects.id)
notes = TreeNode(label="notes.txt", type="file")
tree_view.add_node(notes, parent_id=documents.id)
todo = TreeNode(label="todo.md", type="file")
tree_view.add_node(todo, parent_id=documents.id)
# Expand all nodes to show the full structure
# tree_view.expand_all()
return tree_view
@rt("/")
def index(session):
session_instance = UniqueInstance(session=session,
@@ -120,19 +70,15 @@ def index(session):
btn_popup = mk.label("Popup",
command=add_tab("Popup", Dropdown(layout, "Content", button="button", _id="-dropdown")))
# Create TreeView with sample data
tree_view = create_sample_treeview(layout)
layout.header_left.add(tabs_manager.add_tab_btn())
layout.header_right.add(btn_show_right_drawer)
layout.left_drawer.add(btn_show_instances_debugger, "Debugger")
layout.left_drawer.add(btn_show_commands_debugger, "Debugger")
layout.left_drawer.add(btn_file_upload, "Test")
layout.left_drawer.add(btn_popup, "Test")
layout.left_drawer.add(tree_view, "TreeView")
# data grids
dgs_manager = DataGridsManager(layout, _id="-datagrids")
dgs_manager = DataGridsManager(session_instance)
layout.left_drawer.add_group("Documents", Div("Documents",
dgs_manager.mk_main_icons(),
cls="mf-layout-group flex gap-3"))

View File

@@ -5,10 +5,8 @@ from dataclasses import dataclass
from functools import lru_cache
from typing import Optional
import pandas as pd
from fasthtml.common import NotStr
from fasthtml.components import *
from pandas import DataFrame
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.CycleStateControl import CycleStateControl
@@ -17,23 +15,28 @@ from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEdito
from myfasthtml.controls.DslEditor import DslEditorConf
from myfasthtml.controls.IconsHelper import IconsHelper
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.Mouse import Mouse
from myfasthtml.controls.Panel import Panel, PanelConf
from myfasthtml.controls.Query import Query, QUERY_FILTER
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState
from myfasthtml.controls.helpers import mk, column_type_defaults
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowUiState, \
DatagridSelectionState, DataGridHeaderFooterConf, DatagridEditionState, DataGridColumnUiState, \
DataGridRowSelectionColumnState
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.constants import ColumnType, ROW_INDEX_ID, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID, \
ROW_SELECTION_ID
from myfasthtml.core.constants import ColumnType, FooterAggregation, DATAGRID_PAGE_SIZE, FILTER_INPUT_CID
from myfasthtml.core.data.ColumnDefinition import ColumnDefinition
from myfasthtml.core.data.DataService import DataService
from myfasthtml.core.data.DataServicesManager import DataServicesManager
from myfasthtml.core.dbmanager import DbObject
from myfasthtml.core.dsls import DslsManager
from myfasthtml.core.formatting.dsl.completion.FormattingCompletionEngine import FormattingCompletionEngine
from myfasthtml.core.formatting.dsl.completion.provider import DatagridMetadataProvider
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
from myfasthtml.core.formatting.dsl.parser import DSLParser
from myfasthtml.core.formatting.engine import FormattingEngine
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.core.instances import MultipleInstance, InstancesManager
from myfasthtml.core.optimized_ft import OptimizedDiv
from myfasthtml.core.utils import make_safe_id, merge_classes, make_unique_safe_id, is_null
from myfasthtml.core.utils import merge_classes, is_null
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, add12_filled
@@ -70,8 +73,8 @@ class DatagridState(DbObject):
super().__init__(owner, name=f"{owner.get_id()}#state", save_state=save_state)
self.sidebar_visible: bool = False
self.selected_view: str = None
self.columns: list[DataGridColumnState] = []
self.rows: list[DataGridRowState] = [] # only the rows that have a specific state
self.columns: list[DataGridColumnUiState] = []
self.rows: list[DataGridRowUiState] = [] # only the rows that have a specific state
self.headers: list[DataGridHeaderFooterConf] = []
self.footers: list[DataGridHeaderFooterConf] = []
self.sorted: list = []
@@ -101,20 +104,6 @@ class DatagridSettings(DbObject):
self.enable_edition: bool = True
class DatagridStore(DbObject):
"""
Store Dataframes
"""
def __init__(self, owner, save_state):
with self.initializing():
super().__init__(owner, name=f"{owner.get_id()}#df", save_state=save_state)
self.ne_df = None
self.ns_fast_access = None
self.ns_row_data = None
self.ns_total_rows = None
class Commands(BaseCommands):
def get_page(self, page_index: int):
return Command("GetPage",
@@ -231,11 +220,15 @@ class DataGrid(MultipleInstance):
name, namespace = (conf.name, conf.namespace) if conf else ("No name", "__default__")
self._settings = DatagridSettings(self, save_state=save_state, name=name, namespace=namespace)
self._state = DatagridState(self, save_state=self._settings.save_state)
self._df_store = DatagridStore(self, save_state=self._settings.save_state)
self._formatting_engine = FormattingEngine()
self._columns = None
self.commands = Commands(self)
self.init_from_dataframe(self._df_store.ne_df, init_state=False) # data comes from DatagridStore
# 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)
self._data_service = data_services_manager.restore_service(data_service_id)
self._init_columns()
# add Panel
self._panel = Panel(self, conf=PanelConf(show_display_right=False), _id="-panel")
@@ -253,15 +246,17 @@ class DataGrid(MultipleInstance):
# add Selection Selector
selection_types = {
"cell": mk.icon(grid, tooltip="Cell selection"), # default
"row": mk.icon(row, tooltip="Row selection"),
"column": mk.icon(column, tooltip="Column selection"),
"cell": mk.icon(grid, tooltip="Cell selection")
}
self._selection_mode_selector = CycleStateControl(self,
controls=selection_types,
save_state=False,
_id="-cycle_state")
self._selection_mode_selector.bind_command("CycleState", self.commands.change_selection_mode())
if self._state.selection.selection_mode is None:
self.change_selection_mode()
# add columns manager
self._columns_manager = DataGridColumnsManager(self)
@@ -269,7 +264,8 @@ class DataGrid(MultipleInstance):
self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed())
if self._settings.enable_formatting:
completion_engine = FormattingCompletionEngine(self._parent, self.get_table_name())
provider = InstancesManager.get_by_type(self._session, DatagridMetadataProvider, None)
completion_engine = FormattingCompletionEngine(provider, self.get_table_name())
editor_conf = DslEditorConf(engine_id=completion_engine.get_id())
dsl = FormattingDSL()
self._formatting_editor = DataGridFormattingEditor(self,
@@ -301,7 +297,13 @@ class DataGrid(MultipleInstance):
@property
def _df(self):
return self._df_store.ne_df
if self._data_service is None:
return None
return self._data_service.get_store().ne_df
@property
def _fast_access(self):
return self._data_service.get_store().ns_fast_access
def _apply_sort(self, df):
if df is None:
@@ -344,7 +346,8 @@ class DataGrid(MultipleInstance):
df = self._df.copy()
df = self._apply_sort(df) # need to keep the real type to sort
df = self._apply_filter(df)
self._df_store.ns_total_rows = len(df)
if self._data_service is not None:
self._data_service.get_store().ns_total_rows = len(df)
return df
@@ -375,36 +378,6 @@ 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.
@@ -440,197 +413,55 @@ class DataGrid(MultipleInstance):
if self._state.table_format:
return self._state.table_format
# Get global tables formatting from manager
return self._parent.all_tables_formats
# 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 []
def _init_columns(self):
self._columns = self._state.columns.copy()
# Populate UI state from DataService columns when creating a new grid
columns_defs = self._data_service.columns
columns = []
if self._state.columns:
# we need to make sure that we match the DataGridColumnUiState and the ColumnDefinition
for col_ui_state in self._state.columns:
col_def = [col_def for col_def in columns_defs if col_def.col_id == col_ui_state.col_id][0]
columns.append(DataGridColumnState(col_def, col_ui_state))
self._columns = columns
else:
# init the ui states
ui_states = [DataGridColumnUiState(col_def.col_id) for col_def in columns_defs]
self._state.columns = ui_states # this saves the state of the columns
self._columns = [DataGridColumnState(col_def, col_ui_state)
for col_def, col_ui_state in zip(columns_defs, ui_states)]
if self._settings.enable_edition:
self._columns.insert(0, DataGridColumnState(ROW_SELECTION_ID,
-1,
"",
ColumnType.RowSelection_))
def init_from_dataframe(self, df, init_state=True):
def _get_column_type(dtype):
if pd.api.types.is_integer_dtype(dtype):
return ColumnType.Number
elif pd.api.types.is_float_dtype(dtype):
return ColumnType.Number
elif pd.api.types.is_bool_dtype(dtype):
return ColumnType.Bool
elif pd.api.types.is_datetime64_any_dtype(dtype):
return ColumnType.Datetime
else:
return ColumnType.Text # Default to Text if no match
def _init_columns(_df):
columns = [DataGridColumnState(make_safe_id(col_id),
col_index,
col_id,
_get_column_type(self._df[make_safe_id(col_id)].dtype))
for col_index, col_id in enumerate(_df.columns)]
return columns
def _init_fast_access(_df):
"""
Generates a fast-access dictionary for a DataFrame.
This method converts the columns of the provided DataFrame into NumPy arrays
and stores them as values in a dictionary, using the column names as keys.
This allows for efficient access to the data stored in the DataFrame.
Args:
_df (DataFrame): The input pandas DataFrame whose columns are to be converted
into a dictionary of NumPy arrays.
Returns:
dict: A dictionary where the keys are the column names of the input DataFrame
and the values are the corresponding column values as NumPy arrays.
"""
if _df is None:
return {}
res = {col: _df[col].to_numpy() for col in _df.columns}
res[ROW_INDEX_ID] = _df.index.to_numpy()
return res
def _init_row_data(_df):
"""
Generates a list of row data dictionaries for column references in formatting conditions.
Each dict contains {col_id: value} for a single row, used by FormattingEngine
to evaluate conditions that reference other columns (e.g., {"col": "budget"}).
Args:
_df (DataFrame): The input pandas DataFrame.
Returns:
list[dict]: A list where each element is a dict of column values for that row.
"""
if _df is None:
return []
return _df.to_dict(orient='records')
if df is not None:
self._df_store.ne_df = df
if init_state:
self._df.columns = self._df.columns.map(make_safe_id) # make sure column names are trimmed
self._df_store.save()
self._state.rows = [] # sparse: only rows with non-default state are stored
self._state.columns = _init_columns(df) # use df not self._df to keep the original title
self._init_columns()
self._df_store.ns_fast_access = _init_fast_access(self._df)
self._df_store.ns_row_data = _init_row_data(self._df)
self._df_store.ns_total_rows = len(self._df) if self._df is not None else 0
self._register_existing_formulas()
return self
self._columns.insert(0, DataGridRowSelectionColumnState())
def add_new_column(self, col_def: DataGridColumnState) -> None:
"""Add a new column to the DataGrid.
For Formula columns, only _state.columns is updated; the FormulaEngine
computes values on demand via recalculate_if_needed().
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.
"""Add a new column, delegating data mutation to DataService.
Args:
col_def: Column definition with title and type already set.
col_id is derived from title via make_safe_id.
col_def: Column definition with title and type set. col_id will be
assigned by DataService.
"""
col_def.col_id = make_unique_safe_id(col_def.title, [c.col_id for c in self._state.columns])
if col_def.type == ColumnType.Formula:
col_def.col_index = -1
self._state.columns.append(col_def)
return
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)
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 add_new_row(self, row_data: dict = None) -> None:
"""Add a new row to the DataGrid with incremental updates.
Creates default values based on column types and handles formula columns.
Updates ns_fast_access and ns_row_data incrementally for better performance.
Args:
row_data: Optional dict with initial values. If None, uses type defaults.
"""
import numpy as np
def _get_default_value(col_def: DataGridColumnState):
"""Get default value for a column based on its type."""
if col_def.type == ColumnType.Formula:
return None # Will be calculated by FormulaEngine
return column_type_defaults.get(col_def.type, "")
if row_data is None:
# Create default values for all non-formula, non-selection columns
row_data = {}
for col in self._state.columns:
if col.type not in (ColumnType.Formula, ColumnType.RowSelection_):
row_data[col.col_id] = _get_default_value(col)
# 1. Add row to DataFrame (only non-formula columns)
new_index = len(self._df)
self._df.loc[new_index] = row_data
# 2. Incremental update of ns_fast_access
for col_id, value in row_data.items():
if col_id in self._df_store.ns_fast_access:
self._df_store.ns_fast_access[col_id] = np.append(
self._df_store.ns_fast_access[col_id],
value
)
else:
# First value for this column (rare case)
self._df_store.ns_fast_access[col_id] = np.array([value])
# Update row index
if ROW_INDEX_ID in self._df_store.ns_fast_access:
self._df_store.ns_fast_access[ROW_INDEX_ID] = np.append(
self._df_store.ns_fast_access[ROW_INDEX_ID],
new_index
if self._data_service is not None:
data_col = ColumnDefinition(
col_id="",
col_index=col_def.col_index,
title=col_def.title,
type=col_def.type,
formula=col_def.formula,
)
else:
self._df_store.ns_fast_access[ROW_INDEX_ID] = np.array([new_index])
# 3. Incremental update of ns_row_data
self._df_store.ns_row_data.append(row_data.copy())
# 4. Handle formula columns
engine = self.get_formula_engine()
if engine is not None:
table_name = self.get_table_name()
# Check if there are any formula columns
has_formulas = any(col.type == ColumnType.Formula for col in self._state.columns)
if has_formulas:
try:
# Recalculate all formulas (engine handles efficiency)
engine.recalculate_if_needed(table_name, self._df_store)
except Exception as e:
logger.warning(f"Failed to calculate formulas for new row: {e}")
# 5. Update total row count
self._df_store.ns_total_rows = len(self._df)
# Save changes
self._df_store.save()
self._data_service.add_column(data_col)
col_def.col_id = data_col.col_id
col_def.col_index = data_col.col_index
self._state.columns.append(col_def)
self._init_columns()
self._state.save()
def move_column(self, source_col_id: str, target_col_id: str):
"""Move column to new position. Called via Command from JS."""
@@ -676,7 +507,7 @@ class DataGrid(MultipleInstance):
Returns:
Optimal width in pixels (between 50 and 500)
"""
col_def = next((c for c in self._state.columns if c.col_id == col_id), None)
col_def = next((c for c in self._columns if c.col_id == col_id), None)
if not col_def:
logger.warning(f"calculate_optimal_column_width: column not found {col_id=}")
return 150 # default width
@@ -686,8 +517,8 @@ class DataGrid(MultipleInstance):
# Max data length
max_data_length = 0
if col_id in self._df_store.ns_fast_access:
col_array = self._df_store.ns_fast_access[col_id]
if col_id in self._fast_access:
col_array = self._fast_access[col_id]
if col_array is not None and len(col_array) > 0:
max_data_length = max(len(str(v)) for v in col_array)
@@ -774,7 +605,6 @@ class DataGrid(MultipleInstance):
def change_selection_mode(self):
logger.debug(f"change_selection_mode")
new_state = self._selection_mode_selector.get_state()
logger.debug(f" {new_state=}")
self._state.selection.selection_mode = new_state
self._state.save()
return self.render_partial()
@@ -812,7 +642,8 @@ class DataGrid(MultipleInstance):
self._state.save()
def handle_add_row(self):
self.add_new_row() # Add row to data
if self._data_service is not None:
self._data_service.add_row()
row_index = len(self._df) - 1 # Index of the newly added row
len_columns_1 = len(self._columns) - 1
rows = [self.mk_row(row_index, None, len_columns_1)]
@@ -835,8 +666,17 @@ class DataGrid(MultipleInstance):
return f"{self._settings.namespace}.{self._settings.name}" if self._settings.namespace else self._settings.name
def get_formula_engine(self):
"""Return the FormulaEngine from the DataGridsManager, if available."""
return self._parent.get_formula_engine()
"""Return the shared FormulaEngine from DataServicesManager."""
manager = InstancesManager.get_by_type(self._session, DataServicesManager, None)
return manager.get_formula_engine() if manager is not None else None
@staticmethod
def get_grid_id_from_data_service_id(data_service_id):
return data_service_id.replace(DataService.compute_prefix(), DataGrid.compute_prefix(), 1)
@staticmethod
def get_data_service_id_from_data_grid_id(datagrid_id):
return datagrid_id.replace(DataGrid.compute_prefix(), DataService.compute_prefix(), 1)
def mk_headers(self):
resize_cmd = self.commands.set_column_width()
@@ -917,7 +757,7 @@ class DataGrid(MultipleInstance):
# 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:
col_array = self._df_store.ns_fast_access.get(col_def.col_id)
col_array = self._fast_access.get(col_def.col_id)
if col_array is None or row_index >= len(col_array):
return NotStr('<span class="dt2-cell-content-text truncate">—</span>')
value = col_array[row_index]
@@ -932,7 +772,7 @@ class DataGrid(MultipleInstance):
else:
column_type = ColumnType.Text
else:
value = self._df_store.ns_fast_access[col_def.col_id][row_index]
value = self._fast_access[col_def.col_id][row_index]
# Boolean type - uses cached HTML (only 2 possible values)
if column_type == ColumnType.Bool:
@@ -981,7 +821,7 @@ class DataGrid(MultipleInstance):
if col_def.type == ColumnType.RowSelection_:
return OptimizedDiv(cls="dt2-row-selection")
col_array = self._df_store.ns_fast_access.get(col_def.col_id)
col_array = self._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)
@@ -1013,7 +853,8 @@ class DataGrid(MultipleInstance):
OPTIMIZED: Extract filter keyword once instead of 10,000 times.
OPTIMIZED: Uses OptimizedDiv for rows instead of Div for faster rendering.
"""
self._recalculate_formulas()
if self._data_service is not None:
self._data_service.ensure_ready()
df = self._get_filtered_df()
if df is None:
return []
@@ -1026,7 +867,8 @@ class DataGrid(MultipleInstance):
len_columns_1 = len(self._columns) - 1
rows = [self.mk_row(row_index, filter_keyword_lower, len_columns_1) for row_index in df.index[start:end]]
if self._df_store.ns_total_rows > end:
store = self._data_service.get_store() if self._data_service is not None else None
if store is not None and store.ns_total_rows > end:
rows[-1].attrs.extend(self.commands.get_page(page_index + 1).get_htmx_params(escaped=True))
self._mk_append_add_row_if_needed(rows)
@@ -1184,7 +1026,7 @@ class DataGrid(MultipleInstance):
)
def render(self):
if self._df_store.ne_df is None:
if self._df is None:
return Div("No data to display !")
return Div(
@@ -1201,8 +1043,10 @@ class DataGrid(MultipleInstance):
cls="flex items-center justify-between mb-2"),
self._panel.set_main(self.mk_table_wrapper()),
Script(f"initDataGrid('{self._id}');"),
# Mouse(self, combinations=self._mouse_support, _id="-mouse"),
Keyboard(self, combinations=self._key_support, _id="-keyboard"),
Div(
Mouse(self, combinations=self._mouse_support, _id="-mouse"),
Keyboard(self, combinations=self._key_support, _id="-keyboard"),
),
id=self._id,
cls="grid",
style="height: 100%; grid-template-rows: auto 1fr;"

View File

@@ -159,24 +159,20 @@ class DataGridColumnsManager(MultipleInstance):
return self.mk_column_details(col_def)
def _register_formula(self, col_def) -> None:
"""Register or remove a formula column with the FormulaEngine.
"""Register or remove a formula column with the FormulaEngine via DataService.
Registers only when col_def.type is Formula and the formula text is
non-empty. Removes the formula in all other cases so the engine stays
consistent with the column definition.
"""
engine = self._parent.get_formula_engine()
if engine is None:
data_service = getattr(self._parent, "_data_service", None)
if data_service is None:
return
table = self._parent.get_table_name()
if col_def.type == ColumnType.Formula and col_def.formula:
try:
engine.set_formula(table, col_def.col_id, col_def.formula)
logger.debug("Registered formula for %s.%s", table, col_def.col_id)
except Exception as e:
logger.warning("Formula error for %s.%s: %s", table, col_def.col_id, e)
data_service.register_formula(col_def.col_id, col_def.formula)
logger.debug("Registered formula for col %s", col_def.col_id)
else:
engine.remove_formula(table, col_def.col_id)
data_service.remove_formula(col_def.col_id)
def mk_column_label(self, col_def: DataGridColumnState):
return Div(

View File

@@ -2,7 +2,7 @@ import logging
from collections import defaultdict
from myfasthtml.controls.DslEditor import DslEditor
from myfasthtml.controls.datagrid_objects import DataGridRowState
from myfasthtml.controls.datagrid_objects import DataGridRowUiState
from myfasthtml.core.formatting.dsl import parse_dsl, DSLSyntaxError, ColumnScope, RowScope, CellScope, TableScope, TablesScope
from myfasthtml.core.instances import InstancesManager
@@ -111,7 +111,7 @@ class DataGridFormattingEditor(DslEditor):
for row_index, rules in rows_rules.items():
row_state = next((r for r in state.rows if r.row_id == row_index), None)
if row_state is None:
row_state = DataGridRowState(row_id=row_index)
row_state = DataGridRowUiState(row_id=row_index)
state.rows.append(row_state)
row_state.format = rules

View File

@@ -13,10 +13,8 @@ from myfasthtml.controls.TreeView import TreeView, TreeNode, TreeViewConf
from myfasthtml.controls.helpers import mk
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.dsl.completion.provider import DatagridMetadataProvider
from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS
from myfasthtml.core.formula.engine import FormulaEngine
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
@@ -50,7 +48,7 @@ class Commands(BaseCommands):
return Command("NewGrid",
"New grid",
self._owner,
self._owner.new_grid).htmx(target=f"#{self._owner._tree.get_id()}")
self._owner.handle_new_grid).htmx(target=f"#{self._owner._tree.get_id()}")
def open_from_excel(self, tab_id, file_upload):
return Command("OpenFromExcel",
@@ -81,11 +79,16 @@ class Commands(BaseCommands):
key="DeleteNode")
class DataGridsManager(SingleInstance, DatagridMetadataProvider):
class DataGridsManager(SingleInstance):
"""UI manager for DataGrids.
Responsible for the visual organisation of DataGrids: TreeView, TabsManager,
and document lifecycle (create, open, delete). All data concerns are handled
by DataServicesManager and DataService.
"""
def __init__(self, parent, _id=None):
if not getattr(self, "_is_new_instance", False):
# Skip __init__ if instance already existed
return
super().__init__(parent, _id=_id)
self.commands = Commands(self)
@@ -96,53 +99,47 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager, None)
self._registry = DataGridsRegistry(parent)
# Global presets shared across all DataGrids
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
self.all_tables_formats: list = []
# Formula engine shared across all DataGrids in this session
self._formula_engine = FormulaEngine(
registry_resolver=self._resolve_store_for_table
)
# Data layer — session-scoped singletons
self._data_services_manager = DataServicesManager(self._parent)
def upload_from_source(self):
file_upload = FileUpload(self)
tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload)
file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload))
return self._tabs_manager.show_tab(tab_id)
# ------------------------------------------------------------------
# Grid lifecycle
# ------------------------------------------------------------------
def _create_and_register_grid(self, namespace: str, name: str, df: pd.DataFrame) -> DataGrid:
"""
Create and register a DataGrid.
"""Create a DataGrid and its companion DataService, then register both.
Args:
namespace: Grid namespace
name: Grid name
df: DataFrame to initialize the grid with
namespace: Grid namespace.
name: Grid name.
df: DataFrame to initialise the grid with.
Returns:
Created DataGrid instance
Created DataGrid instance.
"""
table_name = f"{namespace}.{name}" if namespace else name
data_service = self._data_services_manager.create_service(table_name, save_state=True)
data_service.load_dataframe(df)
grid_id = DataGrid.get_grid_id_from_data_service_id(data_service.get_id())
dg_conf = DatagridConf(namespace=namespace, name=name)
dg = DataGrid(self, conf=dg_conf, save_state=True)
dg.init_from_dataframe(df)
dg = DataGrid(self, conf=dg_conf, save_state=True, _id=grid_id)
self._registry.put(namespace, name, dg.get_id())
return dg
def _create_document(self, namespace: str, name: str, datagrid: DataGrid, tab_id: str = None) -> tuple[
str, DocumentDefinition]:
"""
Create a DocumentDefinition and its associated tab.
"""Create a DocumentDefinition and its associated tab.
Args:
namespace: Document namespace
name: Document name
datagrid: Associated DataGrid instance
tab_id: Optional existing tab ID. If None, creates a new tab
namespace: Document namespace.
name: Document name.
datagrid: Associated DataGrid instance.
tab_id: Optional existing tab ID. If None, creates a new tab.
Returns:
Tuple of (tab_id, document)
Tuple of (tab_id, document).
"""
if tab_id is None:
tab_id = self._tabs_manager.create_tab(name, datagrid)
@@ -159,15 +156,14 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
return tab_id, document
def _add_document_to_tree(self, document: DocumentDefinition, parent_id: str) -> TreeNode:
"""
Add a document to the tree view.
"""Add a document node to the tree view.
Args:
document: Document to add
parent_id: Parent node ID in the tree
document: Document to add.
parent_id: Parent node ID in the tree.
Returns:
Created TreeNode
Created TreeNode.
"""
tree_node = TreeNode(
id=document.document_id,
@@ -179,8 +175,17 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
self._tree.add_node(tree_node, parent_id=parent_id)
return tree_node
def new_grid(self):
# Determine parent folder
# ------------------------------------------------------------------
# Commands handlers
# ------------------------------------------------------------------
def upload_from_source(self):
file_upload = FileUpload(self)
tab_id = self._tabs_manager.create_tab("Upload Datagrid", file_upload)
file_upload.set_on_ok(self.commands.open_from_excel(tab_id, file_upload))
return self._tabs_manager.show_tab(tab_id)
def handle_new_grid(self):
selected_id = self._tree.get_selected_id()
if selected_id is None:
parent_id = self._tree.ensure_path("Untitled")
@@ -188,52 +193,40 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
node = self._tree._state.items[selected_id]
if node.type == "folder":
parent_id = selected_id
else: # leaf
else:
parent_id = node.parent
# Get namespace and generate unique name
namespace = self._tree._state.items[parent_id].label
namespace = self._tree.get_state().items[parent_id].label
name = self._generate_unique_sheet_name(parent_id)
# Create and register DataGrid
dg = self._create_and_register_grid(namespace, name, pd.DataFrame())
# Create document and tab
tab_id, document = self._create_document(namespace, name, dg)
# Add to tree
self._add_document_to_tree(document, parent_id)
# UI-specific handling: open parent, select node, start rename
if parent_id not in self._tree._state.opened:
self._tree._state.opened.append(parent_id)
self._tree._state.selected = document.document_id
if parent_id not in self._tree.get_state().opened:
self._tree.get_state().opened.append(parent_id)
self._tree.get_state().selected = document.document_id
self._tree._start_rename(document.document_id)
return self._tree, self._tabs_manager.show_tab(tab_id)
def _generate_unique_sheet_name(self, parent_id: str) -> str:
children = self._tree._state.items[parent_id].children
existing_labels = {self._tree._state.items[c].label for c in children}
children = self._tree.get_state().items[parent_id].children
existing_labels = {self._tree.get_state().items[c].label for c in children}
n = 1
while f"Sheet{n}" in existing_labels:
n += 1
return f"Sheet{n}"
def open_from_excel(self, tab_id, file_upload: FileUpload):
# Read Excel data
excel_content = file_upload.get_content()
df = pd.read_excel(BytesIO(excel_content), file_upload.get_sheet_name())
namespace = file_upload.get_file_basename()
name = file_upload.get_sheet_name()
# Create and register DataGrid
dg = self._create_and_register_grid(namespace, name, df)
# Create document with existing tab
tab_id, document = self._create_document(namespace, name, dg, tab_id=tab_id)
# Add to tree
parent_id = self._tree.ensure_path(document.namespace)
self._add_document_to_tree(document, parent_id)
@@ -243,80 +236,61 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
document_id = self._tree.get_bag(node_id)
try:
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
dg = DataGrid(self, _id=document.datagrid_id) # reload the state & settings
dg = DataGrid(self, _id=document.datagrid_id)
return self._tabs_manager.show_or_create_tab(document.tab_id, document.name, dg)
except StopIteration:
# the selected node is not a document (it's a folder)
return None
def delete_grid(self, node_id):
"""
Delete a grid and all its associated resources.
"""Delete a grid and all its associated resources.
This method is called BEFORE TreeView._delete_node() to ensure we can
access the node's bag to retrieve the document_id.
Called BEFORE TreeView._delete_node() so we can access the node bag.
Args:
node_id: ID of the TreeView node to delete
node_id: ID of the TreeView node to delete.
Returns:
None (TreeView will handle the node removal)
List of UI updates, or None.
"""
document_id = self._tree.get_bag(node_id)
if document_id is None:
# Node is a folder, not a document - nothing to clean up
return None
res = []
try:
# Find the document
document = next(filter(lambda x: x.document_id == document_id, self._state.elements))
# Get the DataGrid instance
dg = DataGrid(self, _id=document.datagrid_id)
# Close the tab
close_tab_res = self._tabs_manager.close_tab(document.tab_id)
res.append(close_tab_res)
# Remove from registry
self._registry.remove(document.datagrid_id)
self._data_services_manager.remove_service(document.datagrid_id)
# Clean up DataGrid (delete DBEngine entries)
dg.delete()
# Remove from InstancesManager
InstancesManager.remove(self._session, document.datagrid_id)
# Remove DocumentDefinition from state
self._state.elements = [d for d in self._state.elements if d.document_id != document_id]
self._state.save()
except StopIteration:
# Document not found - already deleted or invalid state
pass
return res
def create_tab_content(self, tab_id):
"""
Recreate the content for a tab managed by this DataGridsManager.
Called by TabsManager when the content is not in cache (e.g., after restart).
"""Recreate tab content after restart.
Args:
tab_id: ID of the tab to recreate content for
tab_id: ID of the tab to recreate.
Returns:
The recreated component (Panel with DataGrid)
DataGrid instance for the tab.
"""
# Find the document associated with this tab
document = next((d for d in self._state.elements if d.tab_id == tab_id), None)
if document is None:
raise ValueError(f"No document found for tab {tab_id}")
# Recreate the DataGrid with its saved state
dg = DataGrid(self, _id=document.datagrid_id) # reload the state & settings
dg = DataGrid(self, _id=document.datagrid_id)
return dg
def clear_tree(self):
@@ -324,106 +298,22 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
self._tree.clear()
return self._tree
# === DatagridMetadataProvider ===
def list_tables(self):
return self._registry.get_all_tables()
def list_columns(self, table_name):
return self._registry.get_columns(table_name)
def list_column_values(self, table_name, column_name):
return self._registry.get_column_values(table_name, column_name)
def get_row_count(self, table_name):
return self._registry.get_row_count(table_name)
def get_column_type(self, table_name, column_name):
return self._registry.get_column_type(table_name, column_name)
def list_style_presets(self) -> list[str]:
return list(self.style_presets.keys())
def list_format_presets(self) -> list[str]:
return list(self.formatter_presets.keys())
def _resolve_store_for_table(self, table_name: str):
"""
Resolve the DatagridStore for a given table name.
Used by FormulaEngine as the registry_resolver callback.
Args:
table_name: Full table name in ``"namespace.name"`` format.
Returns:
DatagridStore instance or None if not found.
"""
try:
as_fullname_dict = self._registry._get_entries_as_full_name_dict()
grid_id = as_fullname_dict.get(table_name)
if grid_id is None:
return None
datagrid = InstancesManager.get(self._session, grid_id, None)
if datagrid is None:
return None
return datagrid._df_store
except Exception:
return None
def get_style_presets(self) -> dict:
"""Get the global style presets."""
return self.style_presets
def get_formatter_presets(self) -> dict:
"""Get the global formatter presets."""
return self.formatter_presets
def get_formula_engine(self) -> FormulaEngine:
"""The FormulaEngine shared across all DataGrids in this session."""
return self._formula_engine
def add_style_preset(self, name: str, preset: dict):
"""
Add or update a style preset.
Args:
name: Preset name (e.g., "custom_highlight")
preset: Dict with CSS properties (e.g., {"background-color": "yellow", "color": "black"})
"""
self.style_presets[name] = preset
def add_formatter_preset(self, name: str, preset: dict):
"""
Add or update a formatter preset.
Args:
name: Preset name (e.g., "custom_currency")
preset: Dict with formatter config (e.g., {"type": "number", "prefix": "CHF ", "precision": 2})
"""
self.formatter_presets[name] = preset
def remove_style_preset(self, name: str):
"""Remove a style preset."""
if name in self.style_presets:
del self.style_presets[name]
def remove_formatter_preset(self, name: str):
"""Remove a formatter preset."""
if name in self.formatter_presets:
del self.formatter_presets[name]
# === UI ===
# ------------------------------------------------------------------
# UI
# ------------------------------------------------------------------
def mk_main_icons(self):
return Div(
mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()),
mk.icon(table_add20_regular, tooltip="New grid", command=self.commands.new_grid()),
mk.icon(folder_open20_regular, tooltip="Upload from source",
command=self.commands.upload_from_source()),
mk.icon(table_add20_regular, tooltip="New grid",
command=self.commands.new_grid()),
cls="flex"
)
def _mk_tree(self):
conf = TreeViewConf(add_leaf=False, icons={"folder": "database20_regular", "excel": "table20_regular"})
conf = TreeViewConf(add_leaf=False,
icons={"folder": "database20_regular", "excel": "table20_regular"})
tree = TreeView(self, conf=conf, _id="-treeview")
for element in self._state.elements:
parent_id = tree.ensure_path(element.namespace, node_type="folder")

View File

@@ -293,6 +293,9 @@ class TreeView(MultipleInstance):
return self._state.items[node_id].bag
except KeyError:
return None
def get_state(self) -> TreeViewState:
return self._state
def _toggle_node(self, node_id: str):
"""Toggle expand/collapse state of a node."""

View File

@@ -1,10 +1,11 @@
from dataclasses import dataclass, field
from myfasthtml.core.constants import ColumnType, DATAGRID_DEFAULT_COLUMN_WIDTH, ViewType
from myfasthtml.core.constants import DATAGRID_DEFAULT_COLUMN_WIDTH, ViewType, ROW_SELECTION_ID, ColumnType
from myfasthtml.core.data.ColumnDefinition import ColumnDefinition
@dataclass
class DataGridRowState:
class DataGridRowUiState:
row_id: int
visible: bool = True
height: int | None = None
@@ -12,19 +13,32 @@ class DataGridRowState:
@dataclass
class DataGridColumnState:
col_id: str # name of the column: cannot be changed
col_index: int # index of the column in the dataframe: cannot be changed
title: str = None
type: ColumnType = ColumnType.Text
class DataGridColumnUiState:
"""UI presentation state of a DataGrid column.
Holds only the visual properties of a column. Keyed by col_id to match
the corresponding ColumnDefinition held by DataService.
Attributes:
col_id: Column identifier. Must match the col_id in ColumnDefinition.
width: Column width in pixels.
visible: Whether the column is displayed.
format: List of format rules applied during rendering.
"""
col_id: str
visible: bool = True
width: int = DATAGRID_DEFAULT_COLUMN_WIDTH
format: list = field(default_factory=list) #
formula: str = "" # formula expression for ColumnType.Formula columns
format: list = field(default_factory=list)
def copy(self):
props = {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
return DataGridColumnState(**props)
def copy(self) -> "DataGridColumnUiState":
"""Return a shallow copy of this state."""
return DataGridColumnUiState(
col_id=self.col_id,
width=self.width,
visible=self.visible,
format=list(self.format),
)
@dataclass
@@ -56,4 +70,71 @@ class DataGridHeaderFooterConf:
class DatagridView:
name: str
type: ViewType = ViewType.Table
columns: list[DataGridColumnState] = None
columns: list[DataGridColumnUiState] = None
class DataGridColumnState:
def __init__(self, col_def: ColumnDefinition, col_ui_state: DataGridColumnUiState):
self._col_def = col_def
self._col_ui_state = col_ui_state
@property
def col_id(self):
return self._col_def.col_id
@property
def col_index(self):
return self._col_def.col_index
@property
def title(self):
return self._col_def.title
@property
def type(self):
return self._col_def.type
@property
def visible(self):
return self._col_ui_state.visible
@property
def width(self):
return self._col_ui_state.width
@property
def format(self):
return self._col_ui_state.format
class DataGridRowSelectionColumnState(DataGridColumnState):
def __init__(self):
super().__init__(None, None)
@property
def col_id(self):
return ROW_SELECTION_ID
@property
def col_index(self):
return -1
@property
def title(self):
return ""
@property
def type(self):
return ColumnType.RowSelection_
@property
def visible(self):
return True # not used
@property
def width(self):
return 24 # not used
@property
def format(self):
return ""

View File

@@ -37,75 +37,10 @@ class DataGridsRegistry(SingleInstance):
del all_entries[datagrid_id]
self._db_manager.save(DATAGRIDS_REGISTRY_ENTRY_KEY, all_entries)
def get_all_tables(self):
all_entries = self._get_all_entries()
return [f"{namespace}.{name}" for (namespace, name) in all_entries.values()]
def get_columns(self, table_name):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
# load datagrid state
state_id = f"{grid_id}#state"
state = self._db_manager.load(state_id)
return [c.col_id for c in state["columns"]] if state else []
except KeyError:
return []
def get_column_values(self, table_name, column_name):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
# load dataframe from dedicated store
store = self._db_manager.load(f"{grid_id}#df")
df = store["ne_df"] if store else None
return df[column_name].tolist() if df is not None else []
except KeyError:
return []
def get_row_count(self, table_name):
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
# load dataframe from dedicated store
store = self._db_manager.load(f"{grid_id}#df")
df = store["ne_df"] if store else None
return len(df) if df is not None else 0
except KeyError:
return 0
def get_column_type(self, table_name, column_name):
"""
Get the type of a column.
def get_all_entries(self) -> dict:
"""Return all registry entries as {datagrid_id: (namespace, name)}."""
return self._get_all_entries()
Args:
table_name: The DataGrid name
column_name: The column name
Returns:
ColumnType enum value or None if not found
"""
try:
as_fullname_dict = self._get_entries_as_full_name_dict()
grid_id = as_fullname_dict[table_name]
# load datagrid state
state_id = f"{grid_id}#state"
state = self._db_manager.load(state_id)
if state and "columns" in state:
for col in state["columns"]:
if col.col_id == column_name:
return col.type
return None
except KeyError:
return None
def _get_all_entries(self):
return {k: v for k, v in self._db_manager.load(DATAGRIDS_REGISTRY_ENTRY_KEY).items()
if not k.startswith("__")}

View File

@@ -82,6 +82,7 @@ class DataService(MultipleInstance):
super().__init__(parent, _id=_id)
self._state = DataServiceState(self, save_state=save_state)
self._store = DataStore(self, save_state=save_state)
self._init_store()
@property
def columns(self) -> list[ColumnDefinition]:
@@ -128,12 +129,7 @@ class DataService(MultipleInstance):
self._state.columns = self._build_column_definitions(df)
self._state.save()
self._store.ns_fast_access = self._build_fast_access(df)
self._store.ns_row_data = df.to_dict(orient="records")
self._store.ns_total_rows = len(df)
self._store.save()
self._register_existing_formulas()
self._init_store()
# ------------------------------------------------------------------
# Mutations
@@ -302,6 +298,16 @@ class DataService(MultipleInstance):
# Private helpers
# ------------------------------------------------------------------
def _init_store(self):
df = self._store.ne_df
if df is None:
return
self._store.ns_fast_access = self._build_fast_access(df)
self._store.ns_row_data = df.to_dict(orient="records")
self._store.ns_total_rows = len(df)
self._store.save()
self._register_existing_formulas()
def _build_column_definitions(self, df: pd.DataFrame) -> list[ColumnDefinition]:
"""Build ColumnDefinition objects from DataFrame columns.