From 778e5ac69d82181155f8f804ab7083748b63a47e Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Mon, 26 Jan 2026 23:29:51 +0100 Subject: [PATCH] I can apply format rules --- src/myfasthtml/controls/DataGrid.py | 130 +++++++++++++++--- src/myfasthtml/controls/DataGridsManager.py | 11 +- .../core/formatting/condition_evaluator.py | 17 ++- src/myfasthtml/core/formatting/dataclasses.py | 3 + .../core/formatting/formatter_resolver.py | 11 ++ 5 files changed, 144 insertions(+), 28 deletions(-) diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index acbd91a..98cc4fd 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -1,6 +1,7 @@ import html import logging import re +from dataclasses import dataclass from functools import lru_cache from typing import Optional @@ -21,6 +22,8 @@ from myfasthtml.controls.helpers import mk, icons 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 +from myfasthtml.core.formatting.dataclasses import FormatRule, Style, Condition, ConstantFormatter +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 @@ -47,6 +50,12 @@ def _mk_bool_cached(_value): )) +@dataclass +class DatagridConf: + namespace: Optional[str] = None + name: Optional[str] = None + + class DatagridState(DbObject): def __init__(self, owner, save_state): with self.initializing(): @@ -66,14 +75,17 @@ class DatagridState(DbObject): self.ne_df = None self.ns_fast_access = None + self.ns_row_data = None self.ns_total_rows = None class DatagridSettings(DbObject): - def __init__(self, owner, save_state): + def __init__(self, owner, save_state, name, namespace): with self.initializing(): super().__init__(owner, name=f"{owner.get_full_id()}#settings", save_state=save_state) self.save_state = save_state is True + self.namespace: Optional[str] = namespace + self.name: Optional[str] = name self.file_name: Optional[str] = None self.selected_sheet_name: Optional[str] = None self.header_visible: bool = True @@ -148,10 +160,12 @@ class Commands(BaseCommands): class DataGrid(MultipleInstance): - def __init__(self, parent, settings=None, save_state=None, _id=None): + def __init__(self, parent, conf=None, save_state=None, _id=None): super().__init__(parent, _id=_id) - self._settings = settings or DatagridSettings(self, save_state=save_state) + 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._formatting_engine = FormattingEngine() self.commands = Commands(self) self.init_from_dataframe(self._state.ne_df, init_state=False) # state comes from DatagridState @@ -187,6 +201,8 @@ class DataGrid(MultipleInstance): "ctrl+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"}, "shift+click": {"command": self.commands.on_click(), "hx_vals": "js:getCellId()"}, } + + logger.debug(f"DataGrid '{self._get_full_name()}' with id='{self._id}' created.") @property def _df(self): @@ -262,6 +278,9 @@ class DataGrid(MultipleInstance): self._state.selection.selected = pos self._state.save() + def _get_full_name(self): + return f"{self._settings.namespace}.{self._settings.name}" if self._settings.namespace else self._settings.name + def init_from_dataframe(self, df, init_state=True): def _get_column_type(dtype): @@ -310,6 +329,24 @@ class DataGrid(MultipleInstance): 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._state.ne_df = df if init_state: @@ -317,10 +354,55 @@ class DataGrid(MultipleInstance): self._state.rows = [DataGridRowState(row_id) for row_id in self._df.index] self._state.columns = _init_columns(df) # use df not self._df to keep the original title self._state.ns_fast_access = _init_fast_access(self._df) + self._state.ns_row_data = _init_row_data(self._df) self._state.ns_total_rows = len(self._df) if self._df is not None else 0 return self + 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 + + 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 + """ + + # hack to test + if col_def.col_id == "age": + return [ + FormatRule(style=Style(color="green")), + FormatRule(condition=Condition(operator=">", value=18), style=Style(color="red")), + ] + + return [ + FormatRule(condition=Condition(operator="isnan"), formatter=ConstantFormatter(value="-")), + ] + + cell_id = self._get_element_id_from_pos("cell", (row_index, col_pos)) + + if cell_id in self._state.cell_formats: + return self._state.cell_formats[cell_id] + + if row_index < len(self._state.rows): + row_state = self._state.rows[row_index] + if row_state.format: + return row_state.format + + if col_def.format: + return col_def.format + + return None + def set_column_width(self, col_id: str, width: str): """Update column width after resize. Called via Command from JS.""" logger.debug(f"set_column_width: {col_id=} {width=}") @@ -431,35 +513,35 @@ class DataGrid(MultipleInstance): def mk_body_cell_content(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None): """ - OPTIMIZED: Generate cell content with minimal object creation. - - Uses plain strings instead of Label objects when possible - - Accepts pre-computed filter_keyword_lower to avoid repeated dict lookups - - Avoids html.escape when not necessary - - Uses cached boolean HTML (_mk_bool_cached) + Generate cell content with formatting and optional search highlighting. + + Processing order: + 1. Apply formatter (transforms value for display) + 2. Apply style (CSS inline style) + 3. Apply search highlighting (on top of formatted value) """ - def mk_highlighted_text(value_str, css_class): + def mk_highlighted_text(value_str, css_class, style=None): """Return highlighted text as raw HTML string or tuple of Spans.""" + style_attr = f' style="{style}"' if style else '' + if not filter_keyword_lower: - # OPTIMIZATION: Return plain HTML string instead of Label object - # Include "truncate text-sm" to match mk.label() behavior (ellipsis + font size) - return NotStr(f'{value_str}') + return NotStr(f'{value_str}') index = value_str.lower().find(filter_keyword_lower) if index < 0: - return NotStr(f'{value_str}') + return NotStr(f'{value_str}') # Has highlighting - need to use Span objects - # Add "truncate text-sm" to match mk.label() behavior len_keyword = len(filter_keyword_lower) res = [] if index > 0: res.append(Span(value_str[:index], cls=f"{css_class}")) - res.append(Span(value_str[index:index + len_keyword], cls=f"dt2-highlight-1")) + res.append(Span(value_str[index:index + len_keyword], cls="dt2-highlight-1")) if index + len_keyword < len(value_str): res.append(Span(value_str[index + len_keyword:], cls=f"{css_class}")) - return Span(*res, cls=f"{css_class} truncate") if len(res) > 1 else res[0] + return Span(*res, cls=f"{css_class} truncate", style=style) if len(res) > 1 else res[0] column_type = col_def.type value = self._state.ns_fast_access[col_def.col_id][row_index] @@ -472,8 +554,16 @@ class DataGrid(MultipleInstance): if column_type == ColumnType.RowIndex: return NotStr(f'{row_index}') - # Convert value to string - value_str = str(value) + # Get format rules and apply formatting + css_string = None + formatted_value = None + rules = self._get_format_rules(col_pos, row_index, col_def) + if rules: + row_data = self._state.ns_row_data[row_index] if row_index < len(self._state.ns_row_data) else None + css_string, formatted_value = self._formatting_engine.apply_format(rules, value, row_data) + + # Use formatted value or convert to string + value_str = formatted_value if formatted_value is not None else str(value) # OPTIMIZATION: Only escape if necessary (check for HTML special chars with pre-compiled regex) if _HTML_SPECIAL_CHARS_REGEX.search(value_str): @@ -481,9 +571,9 @@ class DataGrid(MultipleInstance): # Number or Text type if column_type == ColumnType.Number: - return mk_highlighted_text(value_str, "dt2-cell-content-number") + return mk_highlighted_text(value_str, "dt2-cell-content-number", css_string) else: - return mk_highlighted_text(value_str, "dt2-cell-content-text") + return mk_highlighted_text(value_str, "dt2-cell-content-text", css_string) def mk_body_cell(self, col_pos, row_index, col_def: DataGridColumnState, filter_keyword_lower=None): """ diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index 6c4f2b3..afbfa16 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -6,7 +6,7 @@ import pandas as pd from fasthtml.components import Div from myfasthtml.controls.BaseCommands import BaseCommands -from myfasthtml.controls.DataGrid import DataGrid +from myfasthtml.controls.DataGrid import DataGrid, DatagridConf from myfasthtml.controls.FileUpload import FileUpload from myfasthtml.controls.TabsManager import TabsManager from myfasthtml.controls.TreeView import TreeView, TreeNode @@ -97,12 +97,15 @@ class DataGridsManager(MultipleInstance): def open_from_excel(self, tab_id, file_upload: FileUpload): excel_content = file_upload.get_content() df = pd.read_excel(BytesIO(excel_content), file_upload.get_sheet_name()) - dg = DataGrid(self._tabs_manager, save_state=True) # first time the Datagrid is created + namespace = file_upload.get_file_basename() + name = file_upload.get_sheet_name() + dg_conf = DatagridConf(namespace=namespace, name=name) + dg = DataGrid(self._tabs_manager, conf=dg_conf, save_state=True) # first time the Datagrid is created dg.init_from_dataframe(df) document = DocumentDefinition( document_id=str(uuid.uuid4()), - namespace=file_upload.get_file_basename(), - name=file_upload.get_sheet_name(), + namespace=namespace, + name=name, type="excel", tab_id=tab_id, datagrid_id=dg.get_id() diff --git a/src/myfasthtml/core/formatting/condition_evaluator.py b/src/myfasthtml/core/formatting/condition_evaluator.py index 57f9772..1aee4f5 100644 --- a/src/myfasthtml/core/formatting/condition_evaluator.py +++ b/src/myfasthtml/core/formatting/condition_evaluator.py @@ -1,3 +1,4 @@ +from numbers import Number from typing import Any from myfasthtml.core.formatting.dataclasses import Condition @@ -32,6 +33,10 @@ class ConditionEvaluator: if condition.operator == "isnotempty": result = not self._is_empty(cell_value) return self._apply_negate(result, condition.negate) + + if condition.operator == "isnan": + result = self._is_nan(cell_value) + return self._apply_negate(result, condition.negate) # For all other operators, None cell_value returns False if cell_value is None: @@ -70,6 +75,10 @@ class ConditionEvaluator: if isinstance(value, str) and value == "": return True return False + + def _is_nan(self, value: Any) -> bool: + """Check if a value is NaN.""" + return isinstance(value, float) and value != value def _apply_negate(self, result: bool, negate: bool) -> bool: """Apply negation if needed.""" @@ -124,7 +133,7 @@ class ConditionEvaluator: """Check if cell_value < compare_value.""" if type(cell_value) != type(compare_value): # Allow int/float comparison - if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)): + if isinstance(cell_value, Number) and isinstance(compare_value, Number): return cell_value < compare_value raise TypeError("Type mismatch") return cell_value < compare_value @@ -132,7 +141,7 @@ class ConditionEvaluator: def _less_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool: """Check if cell_value <= compare_value.""" if type(cell_value) != type(compare_value): - if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)): + if isinstance(cell_value, Number) and isinstance(compare_value, Number): return cell_value <= compare_value raise TypeError("Type mismatch") return cell_value <= compare_value @@ -140,7 +149,7 @@ class ConditionEvaluator: def _greater_than(self, cell_value: Any, compare_value: Any) -> bool: """Check if cell_value > compare_value.""" if type(cell_value) != type(compare_value): - if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)): + if isinstance(cell_value, Number) and isinstance(compare_value, Number): return cell_value > compare_value raise TypeError("Type mismatch") return cell_value > compare_value @@ -148,7 +157,7 @@ class ConditionEvaluator: def _greater_than_or_equal(self, cell_value: Any, compare_value: Any) -> bool: """Check if cell_value >= compare_value.""" if type(cell_value) != type(compare_value): - if isinstance(cell_value, (int, float)) and isinstance(compare_value, (int, float)): + if isinstance(cell_value, Number) and isinstance(compare_value, Number): return cell_value >= compare_value raise TypeError("Type mismatch") return cell_value >= compare_value diff --git a/src/myfasthtml/core/formatting/dataclasses.py b/src/myfasthtml/core/formatting/dataclasses.py index d5899b1..0982f49 100644 --- a/src/myfasthtml/core/formatting/dataclasses.py +++ b/src/myfasthtml/core/formatting/dataclasses.py @@ -121,6 +121,9 @@ class TextFormatter(Formatter): max_length: int = None ellipsis: str = "..." +@dataclass +class ConstantFormatter(Formatter): + value: str = None @dataclass class EnumFormatter(Formatter): diff --git a/src/myfasthtml/core/formatting/formatter_resolver.py b/src/myfasthtml/core/formatting/formatter_resolver.py index bdf0774..f05d1e2 100644 --- a/src/myfasthtml/core/formatting/formatter_resolver.py +++ b/src/myfasthtml/core/formatting/formatter_resolver.py @@ -9,6 +9,7 @@ from myfasthtml.core.formatting.dataclasses import ( BooleanFormatter, TextFormatter, EnumFormatter, + ConstantFormatter, ) from myfasthtml.core.formatting.presets import DEFAULT_FORMATTER_PRESETS @@ -258,6 +259,15 @@ class EnumFormatterResolver(BaseFormatterResolver): ) +class ConstantFormatterResolver(BaseFormatterResolver): + + def resolve(self, formatter: ConstantFormatter, value: Any) -> str: + return formatter.value + + def apply_preset(self, formatter: Formatter, presets: dict) -> Formatter: + return formatter + + class FormatterResolver: """ Main resolver that dispatches to the appropriate formatter resolver. @@ -281,6 +291,7 @@ class FormatterResolver: BooleanFormatter: BooleanFormatterResolver(), TextFormatter: TextFormatterResolver(), EnumFormatter: EnumFormatterResolver(lookup_resolver), + ConstantFormatter: ConstantFormatterResolver() } def resolve(self, formatter: Formatter, value: Any) -> str: