I can apply format rules
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -188,6 +202,8 @@ class DataGrid(MultipleInstance):
|
||||
"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):
|
||||
return self._state.ne_df
|
||||
@@ -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'<span class="{css_class} truncate">{value_str}</span>')
|
||||
return NotStr(f'<span class="{css_class} truncate"{style_attr}>{value_str}</span>')
|
||||
|
||||
index = value_str.lower().find(filter_keyword_lower)
|
||||
if index < 0:
|
||||
return NotStr(f'<span class="{css_class} truncate">{value_str}</span>')
|
||||
return NotStr(f'<span class="{css_class} truncate"{style_attr}>{value_str}</span>')
|
||||
|
||||
# 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'<span class="dt2-cell-content-number truncate">{row_index}</span>')
|
||||
|
||||
# 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):
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from numbers import Number
|
||||
from typing import Any
|
||||
|
||||
from myfasthtml.core.formatting.dataclasses import Condition
|
||||
@@ -33,6 +34,10 @@ class ConditionEvaluator:
|
||||
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:
|
||||
return self._apply_negate(False, condition.negate)
|
||||
@@ -71,6 +76,10 @@ class ConditionEvaluator:
|
||||
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."""
|
||||
return not result if negate else result
|
||||
@@ -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
|
||||
|
||||
@@ -121,6 +121,9 @@ class TextFormatter(Formatter):
|
||||
max_length: int = None
|
||||
ellipsis: str = "..."
|
||||
|
||||
@dataclass
|
||||
class ConstantFormatter(Formatter):
|
||||
value: str = None
|
||||
|
||||
@dataclass
|
||||
class EnumFormatter(Formatter):
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user