diff --git a/docs/DataGrid Formatting.md b/docs/DataGrid Formatting.md index 79520bb..528c28f 100644 --- a/docs/DataGrid Formatting.md +++ b/docs/DataGrid Formatting.md @@ -1,5 +1,32 @@ # DataGrid Formatting +## Implementation Status + +| Component | Status | Location | +|-----------|--------|----------| +| **Core Module** | | `src/myfasthtml/core/formatting/` | +| Dataclasses (Condition, Style, Formatter, FormatRule) | :white_check_mark: Implemented | `dataclasses.py` | +| Style Presets (DaisyUI 5) | :white_check_mark: Implemented | `presets.py` | +| Formatter Presets (EUR, USD, etc.) | :white_check_mark: Implemented | `presets.py` | +| ConditionEvaluator (12 operators) | :white_check_mark: Implemented | `condition_evaluator.py` | +| StyleResolver | :white_check_mark: Implemented | `style_resolver.py` | +| FormatterResolver (Number, Date, Boolean, Text, Enum) | :white_check_mark: Implemented | `formatter_resolver.py` | +| FormattingEngine (facade + conflict resolution) | :white_check_mark: Implemented | `engine.py` | +| **Condition Features** | | | +| `col` parameter (row-level conditions) | :white_check_mark: Implemented | | +| `row` parameter (column-level conditions) | :x: Not implemented | | +| Column reference in value `{"col": "..."}` | :white_check_mark: Implemented | | +| **DataGrid Integration** | | | +| Integration in `mk_body_cell_content()` | :x: Not implemented | | +| DataGridsManager (global presets) | :white_check_mark: Implemented | `DataGridsManager.py` | +| **Tests** | | `tests/core/formatting/` | +| test_condition_evaluator.py | :white_check_mark: ~45 test cases | | +| test_style_resolver.py | :white_check_mark: ~12 test cases | | +| test_formatter_resolver.py | :white_check_mark: ~40 test cases | | +| test_engine.py | :white_check_mark: ~18 test cases | | + +--- + ## Overview This document describes the formatting capabilities for the DataGrid component. @@ -79,17 +106,19 @@ For `value = -5`: Rule 3 wins (same specificity as rule 2, but defined later). ### Fields -| Field | Type | Default | Required | Description | -|------------------|------------------------|---------|----------|---------------------------------------------| -| `operator` | string | - | Yes | Comparison operator | -| `value` | scalar / list / object | - | Depends | Value to compare against | -| `not` | bool | `false` | No | Inverts the condition result | -| `case_sensitive` | bool | `false` | No | Case-sensitive string comparison | -| `col` | string | - | No | Reference column (for row-level conditions) | -| `row` | int | - | No | Reference row (for column-level conditions) | +| Field | Type | Default | Required | Description | Status | +|------------------|------------------------|---------|----------|---------------------------------------------|--------| +| `operator` | string | - | Yes | Comparison operator | :white_check_mark: | +| `value` | scalar / list / object | - | Depends | Value to compare against | :white_check_mark: | +| `not` | bool | `false` | No | Inverts the condition result | :white_check_mark: (as `negate`) | +| `case_sensitive` | bool | `false` | No | Case-sensitive string comparison | :white_check_mark: | +| `col` | string | - | No | Reference column (for row-level conditions) | :white_check_mark: | +| `row` | int | - | No | Reference row (for column-level conditions) | :x: Not implemented | ### Operators +All operators are :white_check_mark: **implemented**. + | Operator | Description | Value Required | |--------------|--------------------------|------------------| | `==` | Equal | Yes | @@ -217,6 +246,8 @@ String comparisons are **case-insensitive by default**. ## Style Structure +:white_check_mark: **Fully implemented** in `style_resolver.py` + ### Fields | Field | Type | Default | Description | @@ -292,6 +323,8 @@ If formatting fails (e.g., non-numeric value for `number` formatter), display `" ## Formatter Types +All formatter types are :white_check_mark: **implemented** in `formatter_resolver.py`. + ### `number` For numbers, currencies, and percentages. @@ -347,7 +380,7 @@ For mapping values to display labels. Also used for Select dropdowns. #### Source Types -**Static mapping:** +**Static mapping:** :white_check_mark: Implemented ```json { @@ -364,7 +397,7 @@ For mapping values to display labels. Also used for Select dropdowns. } ``` -**From another DataGrid:** +**From another DataGrid:** :white_check_mark: Implemented (requires `lookup_resolver` injection) ```json { @@ -429,13 +462,15 @@ formatter_presets = { ## Storage Architecture +:warning: **Structures exist but integration with formatting engine not implemented** + ### Format Storage Location -| Level | Storage | Key | -|------------|------------------------------|---------| -| **Column** | `DataGridColumnState.format` | - | -| **Row** | `DataGridRowState.format` | - | -| **Cell** | `DatagridState.cell_formats` | Cell ID | +| Level | Storage | Key | Status | +|------------|------------------------------|---------|--------| +| **Column** | `DataGridColumnState.format` | - | Structure exists | +| **Row** | `DataGridRowState.format` | - | Structure exists | +| **Cell** | `DatagridState.cell_formats` | Cell ID | Structure exists | ### Cell ID Format @@ -447,18 +482,42 @@ tcell_{datagrid_id}-{row_index}-{col_index} ## DataGridsManager -Global settings stored in `DataGridsManager`: +:white_check_mark: **Implemented** in `src/myfasthtml/controls/DataGridsManager.py` -| Property | Type | Description | -|---------------------|--------|-------------------------------------------| -| `style_presets` | dict | Style presets (primary, success, etc.) | -| `formatter_presets` | dict | Formatter presets (EUR, percentage, etc.) | -| `default_locale` | string | Default locale for number/date formatting | +Global presets stored as instance attributes: + +| Property | Type | Description | Status | +|---------------------|--------|-------------------------------------------|--------| +| `style_presets` | dict | Style presets (primary, success, etc.) | :white_check_mark: | +| `formatter_presets` | dict | Formatter presets (EUR, percentage, etc.) | :white_check_mark: | +| `default_locale` | string | Default locale for number/date formatting | :x: Not implemented | + +**Methods:** + +| Method | Description | +|--------|-------------| +| `get_style_presets()` | Get the global style presets | +| `get_formatter_presets()` | Get the global formatter presets | +| `add_style_preset(name, preset)` | Add or update a style preset | +| `add_formatter_preset(name, preset)` | Add or update a formatter preset | +| `remove_style_preset(name)` | Remove a style preset | +| `remove_formatter_preset(name)` | Remove a formatter preset | + +**Usage:** + +```python +# Add custom presets +manager.add_style_preset("highlight", {"background-color": "yellow", "color": "black"}) +manager.add_formatter_preset("CHF", {"type": "number", "prefix": "CHF ", "precision": 2}) +``` --- ## Future Considerations +All items below are :x: **not implemented**. + +- **`row` parameter for column-level conditions**: Evaluate condition on a specific row - **AND/OR conditions**: Add explicit `and`/`or` operators if `between`/`in` prove insufficient - **Cell references**: Extend to `{"col": "x", "row": 0}` for specific cell and `{"col": "x", "row_offset": -1}` for relative references @@ -478,3 +537,4 @@ Global settings stored in `DataGridsManager`: - **API source for enum**: `{"type": "api", "value": "https://...", ...}` - **Searchable enum**: For large option lists - **Formatter chaining**: Apply multiple formatters in sequence +- **DataGrid integration**: Connect `FormattingEngine` to `DataGrid.mk_body_cell_content()` diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index 9e070c2..6c4f2b3 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -13,6 +13,7 @@ from myfasthtml.controls.TreeView import TreeView, TreeNode from myfasthtml.controls.helpers import mk from myfasthtml.core.commands import Command from myfasthtml.core.dbmanager import DbObject +from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS, DEFAULT_FORMATTER_PRESETS from myfasthtml.core.instances import MultipleInstance, InstancesManager from myfasthtml.icons.fluent_p1 import table_add20_regular from myfasthtml.icons.fluent_p3 import folder_open20_regular @@ -71,6 +72,7 @@ class Commands(BaseCommands): class DataGridsManager(MultipleInstance): + def __init__(self, parent, _id=None): if not getattr(self, "_is_new_instance", False): # Skip __init__ if instance already existed @@ -81,6 +83,10 @@ class DataGridsManager(MultipleInstance): self._tree = self._mk_tree() self._tree.bind_command("SelectNode", self.commands.show_document()) self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager) + + # Global presets shared across all DataGrids + self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy() + self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy() def upload_from_source(self): file_upload = FileUpload(self) @@ -143,6 +149,46 @@ class DataGridsManager(MultipleInstance): self._tree.clear() return self._tree + # === Presets Management === + + 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 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] + def mk_main_icons(self): return Div( mk.icon(folder_open20_regular, tooltip="Upload from source", command=self.commands.upload_from_source()), diff --git a/src/myfasthtml/core/formatting/__init__.py b/src/myfasthtml/core/formatting/__init__.py new file mode 100644 index 0000000..6bd5f48 --- /dev/null +++ b/src/myfasthtml/core/formatting/__init__.py @@ -0,0 +1 @@ +# Formatting module for DataGrid diff --git a/src/myfasthtml/core/formatting/condition_evaluator.py b/src/myfasthtml/core/formatting/condition_evaluator.py new file mode 100644 index 0000000..57f9772 --- /dev/null +++ b/src/myfasthtml/core/formatting/condition_evaluator.py @@ -0,0 +1,191 @@ +from typing import Any + +from myfasthtml.core.formatting.dataclasses import Condition + + +class ConditionEvaluator: + """Evaluates conditions against cell values.""" + + def evaluate(self, condition: Condition, cell_value: Any, row_data: dict = None) -> bool: + """ + Evaluate a condition against a cell value. + + Args: + condition: The condition to evaluate + cell_value: The value of the current cell + row_data: Dict of {col_id: value} for column references (optional) + + Returns: + True if condition is met, False otherwise + """ + # If col parameter is set, use that column's value instead of cell_value + if condition.col is not None: + if row_data is None or condition.col not in row_data: + return self._apply_negate(False, condition.negate) + cell_value = row_data[condition.col] + + # Handle isempty/isnotempty first (they work with None) + if condition.operator == "isempty": + result = self._is_empty(cell_value) + return self._apply_negate(result, condition.negate) + + if condition.operator == "isnotempty": + result = not self._is_empty(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) + + # Resolve the comparison value (might be a column reference) + compare_value = self._resolve_value(condition.value, row_data) + + # If reference resolved to None, return False + if compare_value is None and isinstance(condition.value, dict): + return self._apply_negate(False, condition.negate) + + # Evaluate based on operator + result = self._evaluate_operator( + condition.operator, + cell_value, + compare_value, + condition.case_sensitive + ) + + return self._apply_negate(result, condition.negate) + + def _resolve_value(self, value: Any, row_data: dict) -> Any: + """Resolve a value, handling column references.""" + if isinstance(value, dict) and "col" in value: + if row_data is None: + return None + col_id = value["col"] + return row_data.get(col_id) + return value + + def _is_empty(self, value: Any) -> bool: + """Check if a value is empty (None or empty string).""" + if value is None: + return True + if isinstance(value, str) and value == "": + return True + return False + + def _apply_negate(self, result: bool, negate: bool) -> bool: + """Apply negation if needed.""" + return not result if negate else result + + def _evaluate_operator( + self, + operator: str, + cell_value: Any, + compare_value: Any, + case_sensitive: bool + ) -> bool: + """Evaluate a specific operator.""" + try: + if operator == "==": + return self._equals(cell_value, compare_value, case_sensitive) + elif operator == "!=": + return not self._equals(cell_value, compare_value, case_sensitive) + elif operator == "<": + return self._less_than(cell_value, compare_value) + elif operator == "<=": + return self._less_than_or_equal(cell_value, compare_value) + elif operator == ">": + return self._greater_than(cell_value, compare_value) + elif operator == ">=": + return self._greater_than_or_equal(cell_value, compare_value) + elif operator == "contains": + return self._contains(cell_value, compare_value, case_sensitive) + elif operator == "startswith": + return self._startswith(cell_value, compare_value, case_sensitive) + elif operator == "endswith": + return self._endswith(cell_value, compare_value, case_sensitive) + elif operator == "in": + return self._in_list(cell_value, compare_value) + elif operator == "between": + return self._between(cell_value, compare_value) + else: + return False + except (TypeError, ValueError): + # Type mismatch or invalid operation + return False + + def _equals(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool: + """Check equality, with optional case-insensitive string comparison.""" + if isinstance(cell_value, str) and isinstance(compare_value, str): + if case_sensitive: + return cell_value == compare_value + return cell_value.lower() == compare_value.lower() + return cell_value == compare_value + + def _less_than(self, cell_value: Any, compare_value: Any) -> bool: + """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)): + return cell_value < compare_value + raise TypeError("Type mismatch") + return cell_value < compare_value + + 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)): + return cell_value <= compare_value + raise TypeError("Type mismatch") + return cell_value <= compare_value + + 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)): + return cell_value > compare_value + raise TypeError("Type mismatch") + return cell_value > compare_value + + 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)): + return cell_value >= compare_value + raise TypeError("Type mismatch") + return cell_value >= compare_value + + def _contains(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool: + """Check if cell_value contains compare_value (string).""" + cell_str = str(cell_value) + compare_str = str(compare_value) + if case_sensitive: + return compare_str in cell_str + return compare_str.lower() in cell_str.lower() + + def _startswith(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool: + """Check if cell_value starts with compare_value (string).""" + cell_str = str(cell_value) + compare_str = str(compare_value) + if case_sensitive: + return cell_str.startswith(compare_str) + return cell_str.lower().startswith(compare_str.lower()) + + def _endswith(self, cell_value: Any, compare_value: Any, case_sensitive: bool) -> bool: + """Check if cell_value ends with compare_value (string).""" + cell_str = str(cell_value) + compare_str = str(compare_value) + if case_sensitive: + return cell_str.endswith(compare_str) + return cell_str.lower().endswith(compare_str.lower()) + + def _in_list(self, cell_value: Any, compare_value: list) -> bool: + """Check if cell_value is in the list.""" + if not isinstance(compare_value, list): + raise TypeError("'in' operator requires a list") + return cell_value in compare_value + + def _between(self, cell_value: Any, compare_value: list) -> bool: + """Check if cell_value is between min and max (inclusive).""" + if not isinstance(compare_value, list) or len(compare_value) != 2: + raise TypeError("'between' operator requires a list of [min, max]") + min_val, max_val = compare_value + return min_val <= cell_value <= max_val diff --git a/src/myfasthtml/core/formatting/dataclasses.py b/src/myfasthtml/core/formatting/dataclasses.py new file mode 100644 index 0000000..d5899b1 --- /dev/null +++ b/src/myfasthtml/core/formatting/dataclasses.py @@ -0,0 +1,165 @@ +from dataclasses import dataclass, field +from typing import Any + + +# === Condition === + +@dataclass +class Condition: + """ + Represents a condition for conditional formatting. + + Attributes: + operator: Comparison operator ("==", "!=", "<", "<=", ">", ">=", + "contains", "startswith", "endswith", "in", "between", + "isempty", "isnotempty") + value: Value to compare against (literal, list, or {"col": "..."} for reference) + negate: If True, inverts the condition result + case_sensitive: If True, string comparisons are case-sensitive (default False) + col: Column ID for row-level conditions (evaluate this column instead of current cell) + row: Row index for column-level conditions (evaluate this row instead of current cell) + """ + operator: str + value: Any = None + negate: bool = False + case_sensitive: bool = False + col: str = None + row: int = None + + +# === Style === + +@dataclass +class Style: + """ + Represents style properties for cell formatting. + + Attributes: + preset: Name of a style preset ("primary", "success", "error", etc.) + background_color: Background color (hex, CSS name, or CSS variable) + color: Text color + font_weight: "normal" or "bold" + font_style: "normal" or "italic" + font_size: Font size ("12px", "0.9em") + text_decoration: "none", "underline", or "line-through" + """ + preset: str = None + background_color: str = None + color: str = None + font_weight: str = None + font_style: str = None + font_size: str = None + text_decoration: str = None + + +# === Formatters === + +@dataclass +class Formatter: + """Base class for all formatters.""" + preset: str = None + + +@dataclass +class NumberFormatter(Formatter): + """ + Formatter for numbers, currencies, and percentages. + + Attributes: + prefix: Text before value (e.g., "$") + suffix: Text after value (e.g., " EUR") + thousands_sep: Thousands separator (e.g., ",", " ") + decimal_sep: Decimal separator (e.g., ".", ",") + precision: Number of decimal places + multiplier: Multiply value before display (e.g., 100 for percentage) + """ + prefix: str = "" + suffix: str = "" + thousands_sep: str = "" + decimal_sep: str = "." + precision: int = 0 + multiplier: float = 1.0 + + +@dataclass +class DateFormatter(Formatter): + """ + Formatter for dates and datetimes. + + Attributes: + format: strftime format pattern (default: "%Y-%m-%d") + """ + format: str = "%Y-%m-%d" + + +@dataclass +class BooleanFormatter(Formatter): + """ + Formatter for boolean values. + + Attributes: + true_value: Display string for True + false_value: Display string for False + null_value: Display string for None/null + """ + true_value: str = "true" + false_value: str = "false" + null_value: str = "" + + +@dataclass +class TextFormatter(Formatter): + """ + Formatter for text transformations. + + Attributes: + transform: Text transformation ("uppercase", "lowercase", "capitalize") + max_length: Maximum length before truncation + ellipsis: Suffix when truncated (default: "...") + """ + transform: str = None + max_length: int = None + ellipsis: str = "..." + + +@dataclass +class EnumFormatter(Formatter): + """ + Formatter for mapping values to display labels. + + Attributes: + source: Data source dict with "type" and "value" keys + - {"type": "mapping", "value": {"key": "label", ...}} + - {"type": "datagrid", "value": "grid_id", "value_column": "id", "display_column": "name"} + default: Label for unknown values + allow_empty: Show empty option in Select dropdowns + empty_label: Label for empty option + order_by: Sort order ("source", "display", "value") + """ + source: dict = field(default_factory=dict) + default: str = "" + allow_empty: bool = True + empty_label: str = "-- Select --" + order_by: str = "source" + + +# === Format Rule === + +@dataclass +class FormatRule: + """ + A formatting rule combining condition, style, and formatter. + + Rules: + - style and formatter can appear alone (unconditional formatting) + - condition cannot appear alone - must be paired with style and/or formatter + - If condition is present, style/formatter is applied only if condition is met + + Attributes: + condition: Optional condition for conditional formatting + style: Optional style to apply + formatter: Optional formatter to apply + """ + condition: Condition = None + style: Style = None + formatter: Formatter = None diff --git a/src/myfasthtml/core/formatting/engine.py b/src/myfasthtml/core/formatting/engine.py new file mode 100644 index 0000000..95e8ee6 --- /dev/null +++ b/src/myfasthtml/core/formatting/engine.py @@ -0,0 +1,152 @@ +from typing import Any, Callable + +from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator +from myfasthtml.core.formatting.dataclasses import FormatRule +from myfasthtml.core.formatting.formatter_resolver import FormatterResolver +from myfasthtml.core.formatting.style_resolver import StyleResolver + + +class FormattingEngine: + """ + Main facade for the formatting system. + + Combines: + - ConditionEvaluator: evaluates conditions + - StyleResolver: resolves styles to CSS + - FormatterResolver: formats values for display + - Conflict resolution: handles multiple matching rules + + Usage: + engine = FormattingEngine() + rules = [ + FormatRule(style=Style(preset="error"), condition=Condition(operator="<", value=0)), + FormatRule(formatter=NumberFormatter(preset="EUR")), + ] + css, formatted = engine.apply_format(rules, cell_value=-5.0) + """ + + def __init__( + self, + style_presets: dict = None, + formatter_presets: dict = None, + lookup_resolver: Callable[[str, str, str], dict] = None + ): + """ + Initialize the FormattingEngine. + + Args: + style_presets: Custom style presets. If None, uses defaults. + formatter_presets: Custom formatter presets. If None, uses defaults. + lookup_resolver: Function for resolving enum datagrid sources. + """ + self._condition_evaluator = ConditionEvaluator() + self._style_resolver = StyleResolver(style_presets) + self._formatter_resolver = FormatterResolver(formatter_presets, lookup_resolver) + + def apply_format( + self, + rules: list[FormatRule], + cell_value: Any, + row_data: dict = None + ) -> tuple[str | None, str | None]: + """ + Apply format rules to a cell value. + + Args: + rules: List of FormatRule to evaluate + cell_value: The cell value to format + row_data: Dict of {col_id: value} for column references + + Returns: + Tuple of (css_string, formatted_value): + - css_string: CSS inline style string, or None if no style + - formatted_value: Formatted string, or None if no formatter + """ + if not rules: + return None, None + + # Find all matching rules + matching_rules = self._get_matching_rules(rules, cell_value, row_data) + + if not matching_rules: + return None, None + + # Resolve conflicts to get the winning rule + winning_rule = self._resolve_conflicts(matching_rules) + + if winning_rule is None: + return None, None + + # Apply style + css_string = None + if winning_rule.style: + css_string = self._style_resolver.to_css_string(winning_rule.style) + if css_string == "": + css_string = None + + # Apply formatter + formatted_value = None + if winning_rule.formatter: + formatted_value = self._formatter_resolver.resolve(winning_rule.formatter, cell_value) + + return css_string, formatted_value + + def _get_matching_rules( + self, + rules: list[FormatRule], + cell_value: Any, + row_data: dict = None + ) -> list[FormatRule]: + """ + Get all rules that match the current cell. + + A rule matches if: + - It has no condition (unconditional) + - Its condition evaluates to True + """ + matching = [] + + for rule in rules: + if rule.condition is None: + # Unconditional rule always matches + matching.append(rule) + elif self._condition_evaluator.evaluate(rule.condition, cell_value, row_data): + # Conditional rule matches + matching.append(rule) + + return matching + + def _resolve_conflicts(self, matching_rules: list[FormatRule]) -> FormatRule | None: + """ + Resolve conflicts when multiple rules match. + + Resolution logic: + 1. Specificity = 1 if rule has condition, 0 otherwise + 2. Higher specificity wins + 3. At equal specificity, last rule wins entirely (no fusion) + + Args: + matching_rules: List of rules that matched + + Returns: + The winning FormatRule, or None if no rules + """ + if not matching_rules: + return None + + if len(matching_rules) == 1: + return matching_rules[0] + + # Calculate specificity for each rule + # Specificity = 1 if has condition, 0 otherwise + def get_specificity(rule: FormatRule) -> int: + return 1 if rule.condition is not None else 0 + + # Find the maximum specificity + max_specificity = max(get_specificity(rule) for rule in matching_rules) + + # Filter to rules with max specificity + top_rules = [rule for rule in matching_rules if get_specificity(rule) == max_specificity] + + # Last rule wins among equal specificity + return top_rules[-1] diff --git a/src/myfasthtml/core/formatting/formatter_resolver.py b/src/myfasthtml/core/formatting/formatter_resolver.py new file mode 100644 index 0000000..bdf0774 --- /dev/null +++ b/src/myfasthtml/core/formatting/formatter_resolver.py @@ -0,0 +1,339 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Any, Callable + +from myfasthtml.core.formatting.dataclasses import ( + Formatter, + NumberFormatter, + DateFormatter, + BooleanFormatter, + TextFormatter, + EnumFormatter, +) +from myfasthtml.core.formatting.presets import DEFAULT_FORMATTER_PRESETS + + +# Error indicator when formatting fails +FORMAT_ERROR = "\u26a0" # ⚠ + + +class BaseFormatterResolver(ABC): + """Base class for all formatter resolvers.""" + + @abstractmethod + def resolve(self, formatter: Formatter, value: Any) -> str: + """Format a value using the given formatter.""" + raise NotImplementedError + + @abstractmethod + def apply_preset(self, formatter: Formatter, presets: dict) -> Formatter: + """Apply preset values to create a fully configured formatter.""" + raise NotImplementedError + + +class NumberFormatterResolver(BaseFormatterResolver): + """Resolver for NumberFormatter.""" + + def resolve(self, formatter: NumberFormatter, value: Any) -> str: + if value is None: + return "" + + # Convert to float and apply multiplier + num = float(value) * formatter.multiplier + + # Round to precision + if formatter.precision > 0: + num = round(num, formatter.precision) + else: + num = int(round(num)) + + # Format with decimal separator + if formatter.precision > 0: + int_part = int(num) + dec_part = abs(num - int_part) + dec_str = f"{dec_part:.{formatter.precision}f}"[2:] # Remove "0." + else: + int_part = int(num) + dec_str = "" + + # Format integer part with thousands separator + int_str = self._format_with_thousands(abs(int_part), formatter.thousands_sep) + + # Handle negative + if num < 0: + int_str = "-" + int_str + + # Combine parts + if dec_str: + result = f"{int_str}{formatter.decimal_sep}{dec_str}" + else: + result = int_str + + return f"{formatter.prefix}{result}{formatter.suffix}" + + def _format_with_thousands(self, num: int, sep: str) -> str: + """Format an integer with thousands separator.""" + if not sep: + return str(num) + + result = "" + s = str(num) + for i, digit in enumerate(reversed(s)): + if i > 0 and i % 3 == 0: + result = sep + result + result = digit + result + return result + + def apply_preset(self, formatter: Formatter, presets: dict) -> NumberFormatter: + preset = presets.get(formatter.preset, {}) + + if isinstance(formatter, NumberFormatter): + return NumberFormatter( + preset=formatter.preset, + prefix=formatter.prefix if formatter.prefix != "" else preset.get("prefix", ""), + suffix=formatter.suffix if formatter.suffix != "" else preset.get("suffix", ""), + thousands_sep=formatter.thousands_sep if formatter.thousands_sep != "" else preset.get("thousands_sep", ""), + decimal_sep=formatter.decimal_sep if formatter.decimal_sep != "." else preset.get("decimal_sep", "."), + precision=formatter.precision if formatter.precision != 0 else preset.get("precision", 0), + multiplier=formatter.multiplier if formatter.multiplier != 1.0 else preset.get("multiplier", 1.0), + ) + else: + return NumberFormatter( + preset=formatter.preset, + prefix=preset.get("prefix", ""), + suffix=preset.get("suffix", ""), + thousands_sep=preset.get("thousands_sep", ""), + decimal_sep=preset.get("decimal_sep", "."), + precision=preset.get("precision", 0), + multiplier=preset.get("multiplier", 1.0), + ) + + +class DateFormatterResolver(BaseFormatterResolver): + """Resolver for DateFormatter.""" + + def resolve(self, formatter: DateFormatter, value: Any) -> str: + if value is None: + return "" + + if isinstance(value, datetime): + return value.strftime(formatter.format) + elif isinstance(value, str): + dt = datetime.fromisoformat(value) + return dt.strftime(formatter.format) + else: + raise TypeError(f"Cannot format {type(value)} as date") + + def apply_preset(self, formatter: Formatter, presets: dict) -> DateFormatter: + preset = presets.get(formatter.preset, {}) + + if isinstance(formatter, DateFormatter): + return DateFormatter( + preset=formatter.preset, + format=formatter.format if formatter.format != "%Y-%m-%d" else preset.get("format", "%Y-%m-%d"), + ) + else: + return DateFormatter( + preset=formatter.preset, + format=preset.get("format", "%Y-%m-%d"), + ) + + +class BooleanFormatterResolver(BaseFormatterResolver): + """Resolver for BooleanFormatter.""" + + def resolve(self, formatter: BooleanFormatter, value: Any) -> str: + if value is None: + return formatter.null_value + if value is True or value == 1: + return formatter.true_value + if value is False or value == 0: + return formatter.false_value + return formatter.null_value + + def apply_preset(self, formatter: Formatter, presets: dict) -> BooleanFormatter: + preset = presets.get(formatter.preset, {}) + + if isinstance(formatter, BooleanFormatter): + return BooleanFormatter( + preset=formatter.preset, + true_value=formatter.true_value if formatter.true_value != "true" else preset.get("true_value", "true"), + false_value=formatter.false_value if formatter.false_value != "false" else preset.get("false_value", "false"), + null_value=formatter.null_value if formatter.null_value != "" else preset.get("null_value", ""), + ) + else: + return BooleanFormatter( + preset=formatter.preset, + true_value=preset.get("true_value", "true"), + false_value=preset.get("false_value", "false"), + null_value=preset.get("null_value", ""), + ) + + +class TextFormatterResolver(BaseFormatterResolver): + """Resolver for TextFormatter.""" + + def resolve(self, formatter: TextFormatter, value: Any) -> str: + if value is None: + return "" + + text = str(value) + + # Apply transformation + if formatter.transform == "uppercase": + text = text.upper() + elif formatter.transform == "lowercase": + text = text.lower() + elif formatter.transform == "capitalize": + text = text.capitalize() + + # Apply truncation + if formatter.max_length and len(text) > formatter.max_length: + text = text[:formatter.max_length] + formatter.ellipsis + + return text + + def apply_preset(self, formatter: Formatter, presets: dict) -> TextFormatter: + preset = presets.get(formatter.preset, {}) + + if isinstance(formatter, TextFormatter): + return TextFormatter( + preset=formatter.preset, + transform=formatter.transform or preset.get("transform"), + max_length=formatter.max_length or preset.get("max_length"), + ellipsis=formatter.ellipsis if formatter.ellipsis != "..." else preset.get("ellipsis", "..."), + ) + else: + return TextFormatter( + preset=formatter.preset, + transform=preset.get("transform"), + max_length=preset.get("max_length"), + ellipsis=preset.get("ellipsis", "..."), + ) + + +class EnumFormatterResolver(BaseFormatterResolver): + """Resolver for EnumFormatter.""" + + def __init__(self, lookup_resolver: Callable[[str, str, str], dict] = None): + self.lookup_resolver = lookup_resolver + + def resolve(self, formatter: EnumFormatter, value: Any) -> str: + if value is None: + return formatter.default + + source = formatter.source + if not source: + return str(value) + + source_type = source.get("type") + + if source_type == "mapping": + mapping = source.get("value", {}) + return mapping.get(value, formatter.default) + + elif source_type == "datagrid": + if not self.lookup_resolver: + return str(value) + + grid_id = source.get("value") + value_col = source.get("value_column") + display_col = source.get("display_column") + + mapping = self.lookup_resolver(grid_id, value_col, display_col) + return mapping.get(value, formatter.default) + + return str(value) + + def apply_preset(self, formatter: Formatter, presets: dict) -> EnumFormatter: + preset = presets.get(formatter.preset, {}) + + if isinstance(formatter, EnumFormatter): + return formatter + else: + return EnumFormatter( + preset=formatter.preset, + source=preset.get("source", {}), + default=preset.get("default", ""), + ) + + +class FormatterResolver: + """ + Main resolver that dispatches to the appropriate formatter resolver. + + This is a facade that delegates formatting to specialized resolvers + based on the formatter type. + """ + + def __init__( + self, + formatter_presets: dict = None, + lookup_resolver: Callable[[str, str, str], dict] = None + ): + self.formatter_presets = formatter_presets or DEFAULT_FORMATTER_PRESETS + self.lookup_resolver = lookup_resolver + + # Registry of resolvers by formatter type + self._resolvers: dict[type, BaseFormatterResolver] = { + NumberFormatter: NumberFormatterResolver(), + DateFormatter: DateFormatterResolver(), + BooleanFormatter: BooleanFormatterResolver(), + TextFormatter: TextFormatterResolver(), + EnumFormatter: EnumFormatterResolver(lookup_resolver), + } + + def resolve(self, formatter: Formatter, value: Any) -> str: + """ + Apply formatter to a value. + + Args: + formatter: The Formatter to apply + value: The value to format + + Returns: + Formatted string for display, or FORMAT_ERROR on failure + """ + if formatter is None: + return str(value) if value is not None else "" + + try: + # Get the appropriate resolver + resolver = self._get_resolver(formatter) + if resolver is None: + return str(value) if value is not None else "" + + # Apply preset if specified + formatter = resolver.apply_preset(formatter, self.formatter_presets) + + # Resolve the value + return resolver.resolve(formatter, value) + + except (ValueError, TypeError, AttributeError): + return FORMAT_ERROR + + def _get_resolver(self, formatter: Formatter) -> BaseFormatterResolver | None: + """Get the appropriate resolver for a formatter.""" + # Direct type match + formatter_type = type(formatter) + if formatter_type in self._resolvers: + return self._resolvers[formatter_type] + + # Base Formatter with preset - determine type from preset + if formatter_type == Formatter and formatter.preset: + preset = self.formatter_presets.get(formatter.preset, {}) + preset_type = preset.get("type") + + type_mapping = { + "number": NumberFormatter, + "date": DateFormatter, + "boolean": BooleanFormatter, + "text": TextFormatter, + "enum": EnumFormatter, + } + + target_type = type_mapping.get(preset_type) + if target_type: + return self._resolvers.get(target_type) + + return None diff --git a/src/myfasthtml/core/formatting/presets.py b/src/myfasthtml/core/formatting/presets.py new file mode 100644 index 0000000..d3b96d4 --- /dev/null +++ b/src/myfasthtml/core/formatting/presets.py @@ -0,0 +1,76 @@ +# === Style Presets (DaisyUI 5) === +# Keys use CSS property names (with hyphens) + +DEFAULT_STYLE_PRESETS = { + "primary": { + "background-color": "var(--color-primary)", + "color": "var(--color-primary-content)", + }, + "secondary": { + "background-color": "var(--color-secondary)", + "color": "var(--color-secondary-content)", + }, + "accent": { + "background-color": "var(--color-accent)", + "color": "var(--color-accent-content)", + }, + "neutral": { + "background-color": "var(--color-neutral)", + "color": "var(--color-neutral-content)", + }, + "info": { + "background-color": "var(--color-info)", + "color": "var(--color-info-content)", + }, + "success": { + "background-color": "var(--color-success)", + "color": "var(--color-success-content)", + }, + "warning": { + "background-color": "var(--color-warning)", + "color": "var(--color-warning-content)", + }, + "error": { + "background-color": "var(--color-error)", + "color": "var(--color-error-content)", + }, +} + + +# === Formatter Presets === + +DEFAULT_FORMATTER_PRESETS = { + "EUR": { + "type": "number", + "suffix": " €", + "thousands_sep": " ", + "decimal_sep": ",", + "precision": 2, + }, + "USD": { + "type": "number", + "prefix": "$", + "thousands_sep": ",", + "decimal_sep": ".", + "precision": 2, + }, + "percentage": { + "type": "number", + "suffix": "%", + "precision": 1, + "multiplier": 100, + }, + "short_date": { + "type": "date", + "format": "%d/%m/%Y", + }, + "iso_date": { + "type": "date", + "format": "%Y-%m-%d", + }, + "yes_no": { + "type": "boolean", + "true_value": "Yes", + "false_value": "No", + }, +} diff --git a/src/myfasthtml/core/formatting/style_resolver.py b/src/myfasthtml/core/formatting/style_resolver.py new file mode 100644 index 0000000..10d1979 --- /dev/null +++ b/src/myfasthtml/core/formatting/style_resolver.py @@ -0,0 +1,75 @@ +from myfasthtml.core.formatting.dataclasses import Style +from myfasthtml.core.formatting.presets import DEFAULT_STYLE_PRESETS + + +# Mapping from Python attribute names to CSS property names +PROPERTY_NAME_MAP = { + "background_color": "background-color", + "color": "color", + "font_weight": "font-weight", + "font_style": "font-style", + "font_size": "font-size", + "text_decoration": "text-decoration", +} + + +class StyleResolver: + """Resolves styles by applying presets and explicit properties.""" + + def __init__(self, style_presets: dict = None): + """ + Initialize the StyleResolver. + + Args: + style_presets: Custom style presets dict. If None, uses DEFAULT_STYLE_PRESETS. + """ + self.style_presets = style_presets or DEFAULT_STYLE_PRESETS + + def resolve(self, style: Style) -> dict: + """ + Resolve a Style to CSS properties dict. + + Logic: + 1. If preset is defined, load preset properties + 2. Override with explicit properties (non-None values) + 3. Convert Python names to CSS names + + Args: + style: The Style object to resolve + + Returns: + Dict of CSS properties, e.g. {"background-color": "red", "color": "white"} + """ + if style is None: + return {} + + result = {} + + # Apply preset first + if style.preset and style.preset in self.style_presets: + preset_props = self.style_presets[style.preset] + for css_name, value in preset_props.items(): + result[css_name] = value + + # Override with explicit properties + for py_name, css_name in PROPERTY_NAME_MAP.items(): + value = getattr(style, py_name, None) + if value is not None: + result[css_name] = value + + return result + + def to_css_string(self, style: Style) -> str: + """ + Resolve a Style to a CSS inline string. + + Args: + style: The Style object to resolve + + Returns: + CSS string, e.g. "background-color: red; color: white;" + """ + props = self.resolve(style) + if not props: + return "" + return "; ".join(f"{key}: {value}" for key, value in props.items()) + ";" diff --git a/tests/core/formatting/__init__.py b/tests/core/formatting/__init__.py new file mode 100644 index 0000000..90fbf5a --- /dev/null +++ b/tests/core/formatting/__init__.py @@ -0,0 +1 @@ +# Tests for formatting module diff --git a/tests/core/formatting/test_condition_evaluator.py b/tests/core/formatting/test_condition_evaluator.py new file mode 100644 index 0000000..fc83d76 --- /dev/null +++ b/tests/core/formatting/test_condition_evaluator.py @@ -0,0 +1,233 @@ +import pytest + +from myfasthtml.core.formatting.condition_evaluator import ConditionEvaluator +from myfasthtml.core.formatting.dataclasses import Condition + + +class TestComparisonOperators: + @pytest.mark.parametrize("operator,cell_value,compare_value,expected", [ + # == + ("==", 5, 5, True), + ("==", 5, 10, False), + ("==", "hello", "hello", True), + ("==", "hello", "world", False), + ("==", "Hello", "hello", True), # case insensitive by default + # != + ("!=", 5, 10, True), + ("!=", 5, 5, False), + # < + ("<", 5, 10, True), + ("<", 10, 5, False), + ("<", 5, 5, False), + ("<", 5.0, 10, True), # int/float mix + # <= + ("<=", 5, 10, True), + ("<=", 5, 5, True), + ("<=", 10, 5, False), + # > + (">", 10, 5, True), + (">", 5, 10, False), + (">", 5, 5, False), + # >= + (">=", 10, 5, True), + (">=", 5, 5, True), + (">=", 5, 10, False), + # type mismatch returns False + (">=", 10, "5", False), + ]) + def test_comparison_operators(self, operator, cell_value, compare_value, expected): + evaluator = ConditionEvaluator() + condition = Condition(operator=operator, value=compare_value) + assert evaluator.evaluate(condition, cell_value) == expected + + +class TestStringOperators: + @pytest.mark.parametrize("operator,cell_value,compare_value,case_sensitive,expected", [ + # contains + ("contains", "Hello World", "World", False, True), + ("contains", "Hello World", "world", False, True), + ("contains", "Hello World", "world", True, False), + ("contains", "Hello World", "xyz", False, False), + # startswith + ("startswith", "Hello World", "Hello", False, True), + ("startswith", "Hello World", "hello", False, True), + ("startswith", "Hello World", "hello", True, False), + ("startswith", "Hello World", "World", False, False), + # endswith + ("endswith", "Hello World", "World", False, True), + ("endswith", "Hello World", "world", False, True), + ("endswith", "Hello World", "world", True, False), + ("endswith", "Hello World", "Hello", False, False), + ]) + def test_string_operators(self, operator, cell_value, compare_value, case_sensitive, expected): + evaluator = ConditionEvaluator() + condition = Condition(operator=operator, value=compare_value, case_sensitive=case_sensitive) + assert evaluator.evaluate(condition, cell_value) == expected + + def test_string_operators_convert_non_strings(self): + """String operators should convert non-string values to strings.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="contains", value="23") + assert evaluator.evaluate(condition, 123) is True + + +class TestCollectionOperators: + @pytest.mark.parametrize("operator,cell_value,compare_value,expected", [ + # in + ("in", "A", ["A", "B", "C"], True), + ("in", "D", ["A", "B", "C"], False), + ("in", 1, [1, 2, 3], True), + ("in", 4, [1, 2, 3], False), + # between (inclusive) + ("between", 5, [1, 10], True), + ("between", 1, [1, 10], True), + ("between", 10, [1, 10], True), + ("between", 0, [1, 10], False), + ("between", 11, [1, 10], False), + ("between", 5.5, [1, 10], True), + ]) + def test_collection_operators(self, operator, cell_value, compare_value, expected): + evaluator = ConditionEvaluator() + condition = Condition(operator=operator, value=compare_value) + assert evaluator.evaluate(condition, cell_value) == expected + + +class TestNullOperators: + @pytest.mark.parametrize("operator,cell_value,expected", [ + # isempty + ("isempty", None, True), + ("isempty", "", True), + ("isempty", "hello", False), + ("isempty", 0, False), + ("isempty", False, False), + # isnotempty + ("isnotempty", "hello", True), + ("isnotempty", 0, True), + ("isnotempty", False, True), + ("isnotempty", None, False), + ("isnotempty", "", False), + ]) + def test_null_operators(self, operator, cell_value, expected): + evaluator = ConditionEvaluator() + condition = Condition(operator=operator) + assert evaluator.evaluate(condition, cell_value) == expected + + +class TestNegation: + @pytest.mark.parametrize("operator,cell_value,compare_value,negate,expected", [ + ("==", 5, 5, False, True), + ("==", 5, 5, True, False), + ("==", 5, 10, True, True), + ("in", "A", ["A", "B"], False, True), + ("in", "A", ["A", "B"], True, False), + ("in", "C", ["A", "B"], True, True), + ("contains", "hello", "ell", False, True), + ("contains", "hello", "ell", True, False), + ("isempty", None, None, False, True), + ("isempty", None, None, True, False), + ]) + def test_negation(self, operator, cell_value, compare_value, negate, expected): + evaluator = ConditionEvaluator() + condition = Condition(operator=operator, value=compare_value, negate=negate) + assert evaluator.evaluate(condition, cell_value) == expected + + +class TestColumnReference: + def test_i_can_evaluate_with_column_reference_in_value(self): + """Compare cell value with another column's value using value={"col": ...}.""" + evaluator = ConditionEvaluator() + condition = Condition(operator=">", value={"col": "budget"}) + row_data = {"budget": 100, "actual": 150} + assert evaluator.evaluate(condition, cell_value=150, row_data=row_data) is True + assert evaluator.evaluate(condition, cell_value=50, row_data=row_data) is False + + def test_i_can_evaluate_with_col_parameter(self): + """Row-level condition: evaluate based on specific column using col parameter.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="==", value="error", col="status") + row_data = {"status": "error", "name": "Task 1"} + # cell_value is ignored when col is set + assert evaluator.evaluate(condition, cell_value="anything", row_data=row_data) is True + + def test_i_can_evaluate_col_parameter_with_non_matching_value(self): + """Row-level condition returns False when column value doesn't match.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="==", value="error", col="status") + row_data = {"status": "ok", "name": "Task 1"} + assert evaluator.evaluate(condition, cell_value="anything", row_data=row_data) is False + + def test_col_parameter_missing_column_returns_false(self): + """When col parameter references non-existent column, return False.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="==", value="error", col="missing") + row_data = {"status": "error"} + assert evaluator.evaluate(condition, cell_value="anything", row_data=row_data) is False + + def test_col_parameter_without_row_data_returns_false(self): + """When col parameter is set but no row_data provided, return False.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="==", value="error", col="status") + assert evaluator.evaluate(condition, cell_value="anything", row_data=None) is False + + +class TestEdgeCases: + def test_null_cell_value_returns_false(self): + """None cell value returns False for comparison operators.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="==", value=5) + assert evaluator.evaluate(condition, cell_value=None) is False + + def test_null_reference_returns_false(self): + """Column reference resolving to None returns False.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="==", value={"col": "other"}) + row_data = {"other": None} + assert evaluator.evaluate(condition, cell_value=5, row_data=row_data) is False + + def test_missing_reference_column_returns_false(self): + """Missing column in row_data returns False.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="==", value={"col": "missing"}) + row_data = {"other": 10} + assert evaluator.evaluate(condition, cell_value=5, row_data=row_data) is False + + def test_no_row_data_for_reference_returns_false(self): + """Column reference without row_data returns False.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="==", value={"col": "other"}) + assert evaluator.evaluate(condition, cell_value=5, row_data=None) is False + + @pytest.mark.parametrize("cell_value,compare_value", [ + ("abc", 5), + (5, "abc"), + ([1, 2], 5), + ({"a": 1}, 5), + ]) + def test_type_mismatch_returns_false(self, cell_value, compare_value): + """Type mismatch in comparison returns False.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="<", value=compare_value) + assert evaluator.evaluate(condition, cell_value) is False + + def test_unknown_operator_returns_false(self): + """Unknown operator returns False.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="unknown", value=5) + assert evaluator.evaluate(condition, cell_value=5) is False + + +class TestCaseSensitiveEquals: + def test_case_sensitive_string_equals(self): + """Case sensitive string comparison with ==.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="==", value="Hello", case_sensitive=True) + assert evaluator.evaluate(condition, "Hello") is True + assert evaluator.evaluate(condition, "hello") is False + + def test_case_insensitive_string_equals_default(self): + """Case insensitive string comparison is default.""" + evaluator = ConditionEvaluator() + condition = Condition(operator="==", value="Hello", case_sensitive=False) + assert evaluator.evaluate(condition, "Hello") is True + assert evaluator.evaluate(condition, "hello") is True + assert evaluator.evaluate(condition, "HELLO") is True diff --git a/tests/core/formatting/test_engine.py b/tests/core/formatting/test_engine.py new file mode 100644 index 0000000..61316b1 --- /dev/null +++ b/tests/core/formatting/test_engine.py @@ -0,0 +1,282 @@ +import pytest + +from myfasthtml.core.formatting.dataclasses import ( + Condition, + Style, + Formatter, + NumberFormatter, + FormatRule, +) +from myfasthtml.core.formatting.engine import FormattingEngine + + +class TestApplyFormat: + def test_apply_format_with_style_only(self): + """Rule with style only returns CSS string.""" + engine = FormattingEngine() + rules = [FormatRule(style=Style(background_color="red", color="white"))] + + css, formatted = engine.apply_format(rules, cell_value=42) + + assert css is not None + assert "background-color: red" in css + assert "color: white" in css + assert formatted is None + + def test_apply_format_with_formatter_only(self): + """Rule with formatter only returns formatted value.""" + engine = FormattingEngine() + rules = [FormatRule(formatter=NumberFormatter(precision=2, suffix=" €"))] + + css, formatted = engine.apply_format(rules, cell_value=1234.5) + + assert css is None + assert formatted == "1234.50 €" + + def test_apply_format_with_style_and_formatter(self): + """Rule with both style and formatter returns both.""" + engine = FormattingEngine() + rules = [ + FormatRule( + style=Style(color="green"), + formatter=NumberFormatter(precision=2) + ) + ] + + css, formatted = engine.apply_format(rules, cell_value=42.567) + + assert css is not None + assert "color: green" in css + assert formatted == "42.57" + + def test_apply_format_condition_met(self): + """Conditional rule applies when condition is met.""" + engine = FormattingEngine() + rules = [ + FormatRule( + condition=Condition(operator="<", value=0), + style=Style(color="red") + ) + ] + + css, formatted = engine.apply_format(rules, cell_value=-5) + + assert css is not None + assert "color: red" in css + + def test_apply_format_condition_not_met(self): + """Conditional rule does not apply when condition is not met.""" + engine = FormattingEngine() + rules = [ + FormatRule( + condition=Condition(operator="<", value=0), + style=Style(color="red") + ) + ] + + css, formatted = engine.apply_format(rules, cell_value=5) + + assert css is None + assert formatted is None + + def test_empty_rules_returns_none(self): + """Empty rules list returns (None, None).""" + engine = FormattingEngine() + + css, formatted = engine.apply_format([], cell_value=42) + + assert css is None + assert formatted is None + + +class TestConflictResolution: + def test_unconditional_rule_always_applies(self): + """Unconditional rule (no condition) always applies.""" + engine = FormattingEngine() + rules = [FormatRule(style=Style(color="gray"))] + + css, _ = engine.apply_format(rules, cell_value="anything") + + assert css is not None + assert "color: gray" in css + + def test_multiple_unconditional_rules_last_wins(self): + """Among unconditional rules, last one wins.""" + engine = FormattingEngine() + rules = [ + FormatRule(style=Style(color="red")), + FormatRule(style=Style(color="blue")), + FormatRule(style=Style(color="green")), + ] + + css, _ = engine.apply_format(rules, cell_value=42) + + assert "color: green" in css + assert "color: red" not in css + assert "color: blue" not in css + + def test_conditional_beats_unconditional(self): + """Conditional rule (higher specificity) beats unconditional.""" + engine = FormattingEngine() + rules = [ + FormatRule(style=Style(color="gray")), # unconditional, specificity=0 + FormatRule( + condition=Condition(operator="<", value=0), + style=Style(color="red") # conditional, specificity=1 + ), + ] + + css, _ = engine.apply_format(rules, cell_value=-5) + + assert "color: red" in css + assert "color: gray" not in css + + def test_conditional_not_met_falls_back_to_unconditional(self): + """When conditional doesn't match, unconditional applies.""" + engine = FormattingEngine() + rules = [ + FormatRule(style=Style(color="gray")), # unconditional + FormatRule( + condition=Condition(operator="<", value=0), + style=Style(color="red") # doesn't match + ), + ] + + css, _ = engine.apply_format(rules, cell_value=5) # positive, condition not met + + assert "color: gray" in css + + def test_multiple_conditional_last_wins(self): + """Among conditional rules with same specificity, last wins.""" + engine = FormattingEngine() + rules = [ + FormatRule( + condition=Condition(operator="<", value=10), + style=Style(color="red") + ), + FormatRule( + condition=Condition(operator="<", value=10), + style=Style(color="blue") + ), + ] + + css, _ = engine.apply_format(rules, cell_value=5) + + assert "color: blue" in css + assert "color: red" not in css + + def test_spec_example_value_minus_5(self): + """ + Example from spec: value=-5 + Rule 1: unconditional gray + Rule 2: <0 -> red + Rule 3: ==-5 -> black + + Both rule 2 and 3 match with same specificity (1). + Rule 3 is last, so black wins. + """ + engine = FormattingEngine() + rules = [ + FormatRule(style=Style(color="gray")), + FormatRule( + condition=Condition(operator="<", value=0), + style=Style(color="red") + ), + FormatRule( + condition=Condition(operator="==", value=-5), + style=Style(color="black") + ), + ] + + css, _ = engine.apply_format(rules, cell_value=-5) + + assert "color: black" in css + + def test_spec_example_value_minus_3(self): + """ + Same rules as above but value=-3. + Rule 3 (==-5) doesn't match. + Rule 2 (<0) matches and beats rule 1 (unconditional). + """ + engine = FormattingEngine() + rules = [ + FormatRule(style=Style(color="gray")), + FormatRule( + condition=Condition(operator="<", value=0), + style=Style(color="red") + ), + FormatRule( + condition=Condition(operator="==", value=-5), + style=Style(color="black") + ), + ] + + css, _ = engine.apply_format(rules, cell_value=-3) + + assert "color: red" in css + + +class TestWithRowData: + def test_condition_with_column_reference(self): + """Condition can reference another column.""" + engine = FormattingEngine() + rules = [ + FormatRule( + condition=Condition(operator=">", value={"col": "budget"}), + style=Style(color="red") + ) + ] + row_data = {"budget": 100, "actual": 150} + + css, _ = engine.apply_format(rules, cell_value=150, row_data=row_data) + + assert "color: red" in css + + def test_condition_with_col_parameter(self): + """Row-level condition using col parameter.""" + engine = FormattingEngine() + rules = [ + FormatRule( + condition=Condition(operator="==", value="error", col="status"), + style=Style(preset="error") + ) + ] + row_data = {"status": "error", "value": 42} + + css, _ = engine.apply_format(rules, cell_value=42, row_data=row_data) + + assert css is not None + assert "background-color" in css + + +class TestPresets: + def test_style_preset(self): + """Style preset is resolved correctly.""" + engine = FormattingEngine() + rules = [FormatRule(style=Style(preset="success"))] + + css, _ = engine.apply_format(rules, cell_value=42) + + assert "var(--color-success)" in css + + def test_formatter_preset(self): + """Formatter preset is resolved correctly.""" + engine = FormattingEngine() + rules = [FormatRule(formatter=Formatter(preset="EUR"))] + + _, formatted = engine.apply_format(rules, cell_value=1234.56) + + assert formatted == "1 234,56 €" + + def test_custom_presets(self): + """Custom presets can be injected.""" + custom_style_presets = { + "custom": {"background-color": "purple", "color": "yellow"} + } + engine = FormattingEngine(style_presets=custom_style_presets) + rules = [FormatRule(style=Style(preset="custom"))] + + css, _ = engine.apply_format(rules, cell_value=42) + + assert "background-color: purple" in css + assert "color: yellow" in css diff --git a/tests/core/formatting/test_formatter_resolver.py b/tests/core/formatting/test_formatter_resolver.py new file mode 100644 index 0000000..c1335b5 --- /dev/null +++ b/tests/core/formatting/test_formatter_resolver.py @@ -0,0 +1,274 @@ +from datetime import datetime + +import pytest + +from myfasthtml.core.formatting.dataclasses import ( + Formatter, + NumberFormatter, + DateFormatter, + BooleanFormatter, + TextFormatter, + EnumFormatter, +) +from myfasthtml.core.formatting.formatter_resolver import FormatterResolver, FORMAT_ERROR + + +class TestNumberFormatter: + @pytest.mark.parametrize("value,precision,thousands_sep,decimal_sep,prefix,suffix,multiplier,expected", [ + # Basic formatting + (1234.567, 2, "", ".", "", "", 1.0, "1234.57"), + (1234.5, 2, "", ".", "", "", 1.0, "1234.50"), + (1234, 0, "", ".", "", "", 1.0, "1234"), + # With thousands separator + (1234567.89, 2, " ", ",", "", "", 1.0, "1 234 567,89"), + (1234567, 0, ",", ".", "", "", 1.0, "1,234,567"), + # With prefix/suffix + (1234.56, 2, " ", ",", "", " €", 1.0, "1 234,56 €"), + (1234.56, 2, ",", ".", "$", "", 1.0, "$1,234.56"), + # With multiplier (percentage) + (0.156, 1, "", ".", "", "%", 100, "15.6%"), + (0.5, 0, "", ".", "", "%", 100, "50%"), + # Negative numbers + (-1234.56, 2, " ", ",", "", " €", 1.0, "-1 234,56 €"), + # Zero + (0, 2, "", ".", "", "", 1.0, "0.00"), + # Small numbers + (0.99, 2, "", ".", "", "", 1.0, "0.99"), + ]) + def test_number_formatting(self, value, precision, thousands_sep, decimal_sep, prefix, suffix, multiplier, expected): + resolver = FormatterResolver() + formatter = NumberFormatter( + precision=precision, + thousands_sep=thousands_sep, + decimal_sep=decimal_sep, + prefix=prefix, + suffix=suffix, + multiplier=multiplier, + ) + assert resolver.resolve(formatter, value) == expected + + def test_number_none_value(self): + resolver = FormatterResolver() + formatter = NumberFormatter(precision=2) + assert resolver.resolve(formatter, None) == "" + + def test_number_invalid_value_returns_error(self): + resolver = FormatterResolver() + formatter = NumberFormatter(precision=2) + assert resolver.resolve(formatter, "not a number") == FORMAT_ERROR + + +class TestDateFormatter: + @pytest.mark.parametrize("value,format,expected", [ + (datetime(2024, 1, 15), "%Y-%m-%d", "2024-01-15"), + (datetime(2024, 1, 15), "%d/%m/%Y", "15/01/2024"), + (datetime(2024, 1, 15, 14, 30), "%Y-%m-%d %H:%M", "2024-01-15 14:30"), + (datetime(2024, 12, 31), "%d %b %Y", "31 Dec 2024"), + ]) + def test_date_formatting(self, value, format, expected): + resolver = FormatterResolver() + formatter = DateFormatter(format=format) + assert resolver.resolve(formatter, value) == expected + + def test_date_from_iso_string(self): + resolver = FormatterResolver() + formatter = DateFormatter(format="%d/%m/%Y") + assert resolver.resolve(formatter, "2024-01-15") == "15/01/2024" + + def test_date_none_value(self): + resolver = FormatterResolver() + formatter = DateFormatter() + assert resolver.resolve(formatter, None) == "" + + def test_date_invalid_value_returns_error(self): + resolver = FormatterResolver() + formatter = DateFormatter() + assert resolver.resolve(formatter, "not a date") == FORMAT_ERROR + + +class TestBooleanFormatter: + @pytest.mark.parametrize("value,true_val,false_val,null_val,expected", [ + (True, "Yes", "No", "-", "Yes"), + (False, "Yes", "No", "-", "No"), + (None, "Yes", "No", "-", "-"), + (True, "Oui", "Non", "", "Oui"), + (False, "Oui", "Non", "", "Non"), + # Integer equivalents + (1, "Yes", "No", "-", "Yes"), + (0, "Yes", "No", "-", "No"), + ]) + def test_boolean_formatting(self, value, true_val, false_val, null_val, expected): + resolver = FormatterResolver() + formatter = BooleanFormatter( + true_value=true_val, + false_value=false_val, + null_value=null_val, + ) + assert resolver.resolve(formatter, value) == expected + + +class TestTextFormatter: + @pytest.mark.parametrize("value,transform,max_length,ellipsis,expected", [ + # Transform only + ("hello", "uppercase", None, "...", "HELLO"), + ("HELLO", "lowercase", None, "...", "hello"), + ("hello world", "capitalize", None, "...", "Hello world"), + ("hELLO", "capitalize", None, "...", "Hello"), + # Truncation only + ("hello world", None, 5, "...", "hello..."), + ("hello world", None, 5, "~", "hello~"), + ("hi", None, 5, "...", "hi"), # No truncation needed + # Transform + truncation + ("hello world", "uppercase", 5, "...", "HELLO..."), + # None value + (None, "uppercase", None, "...", ""), + ]) + def test_text_formatting(self, value, transform, max_length, ellipsis, expected): + resolver = FormatterResolver() + formatter = TextFormatter( + transform=transform, + max_length=max_length, + ellipsis=ellipsis, + ) + assert resolver.resolve(formatter, value) == expected + + def test_text_non_string_converted(self): + resolver = FormatterResolver() + formatter = TextFormatter(transform="uppercase") + assert resolver.resolve(formatter, 123) == "123" + + +class TestEnumFormatter: + def test_enum_mapping(self): + resolver = FormatterResolver() + formatter = EnumFormatter( + source={ + "type": "mapping", + "value": { + "draft": "Brouillon", + "pending": "En attente", + "approved": "Approuv\u00e9", + } + } + ) + assert resolver.resolve(formatter, "draft") == "Brouillon" + assert resolver.resolve(formatter, "pending") == "En attente" + assert resolver.resolve(formatter, "approved") == "Approuv\u00e9" + + def test_enum_default_value(self): + resolver = FormatterResolver() + formatter = EnumFormatter( + source={ + "type": "mapping", + "value": {"a": "A", "b": "B"} + }, + default="Unknown" + ) + assert resolver.resolve(formatter, "unknown_key") == "Unknown" + + def test_enum_none_value(self): + resolver = FormatterResolver() + formatter = EnumFormatter( + source={"type": "mapping", "value": {"a": "A"}}, + default="N/A" + ) + assert resolver.resolve(formatter, None) == "N/A" + + def test_enum_with_lookup_resolver(self): + def mock_lookup(grid_id, value_col, display_col): + if grid_id == "categories_grid": + return {1: "Electronics", 2: "Books", 3: "Clothing"} + return {} + + resolver = FormatterResolver(lookup_resolver=mock_lookup) + formatter = EnumFormatter( + source={ + "type": "datagrid", + "value": "categories_grid", + "value_column": "id", + "display_column": "name", + }, + default="Unknown category" + ) + assert resolver.resolve(formatter, 1) == "Electronics" + assert resolver.resolve(formatter, 2) == "Books" + assert resolver.resolve(formatter, 99) == "Unknown category" + + def test_enum_datagrid_without_resolver(self): + resolver = FormatterResolver() # No lookup_resolver + formatter = EnumFormatter( + source={ + "type": "datagrid", + "value": "some_grid", + "value_column": "id", + "display_column": "name", + } + ) + # Should fall back to string representation + assert resolver.resolve(formatter, 1) == "1" + + def test_enum_empty_source(self): + resolver = FormatterResolver() + formatter = EnumFormatter(source={}) + assert resolver.resolve(formatter, "value") == "value" + + +class TestPresets: + def test_eur_preset(self): + resolver = FormatterResolver() + formatter = Formatter(preset="EUR") + assert resolver.resolve(formatter, 1234.56) == "1 234,56 \u20ac" + + def test_usd_preset(self): + resolver = FormatterResolver() + formatter = Formatter(preset="USD") + assert resolver.resolve(formatter, 1234.56) == "$1,234.56" + + def test_percentage_preset(self): + resolver = FormatterResolver() + formatter = Formatter(preset="percentage") + assert resolver.resolve(formatter, 0.156) == "15.6%" + + def test_short_date_preset(self): + resolver = FormatterResolver() + formatter = Formatter(preset="short_date") + assert resolver.resolve(formatter, datetime(2024, 1, 15)) == "15/01/2024" + + def test_iso_date_preset(self): + resolver = FormatterResolver() + formatter = Formatter(preset="iso_date") + assert resolver.resolve(formatter, datetime(2024, 1, 15)) == "2024-01-15" + + def test_yes_no_preset(self): + resolver = FormatterResolver() + formatter = Formatter(preset="yes_no") + assert resolver.resolve(formatter, True) == "Yes" + assert resolver.resolve(formatter, False) == "No" + + def test_unknown_preset_passthrough(self): + resolver = FormatterResolver() + formatter = Formatter(preset="unknown_preset") + assert resolver.resolve(formatter, "value") == "value" + + +class TestPresetOverride: + def test_preset_with_custom_precision(self): + """NumberFormatter with preset can override precision.""" + resolver = FormatterResolver() + formatter = NumberFormatter(preset="EUR", precision=3) + # EUR preset has precision=2, but we override to 3 + assert resolver.resolve(formatter, 1234.5678) == "1 234,568 \u20ac" + + def test_preset_with_custom_suffix(self): + """NumberFormatter with preset can override suffix.""" + resolver = FormatterResolver() + formatter = NumberFormatter(preset="EUR", suffix=" euros") + assert resolver.resolve(formatter, 1234.56) == "1 234,56 euros" + + +class TestNoneFormatter: + def test_none_formatter_returns_string(self): + resolver = FormatterResolver() + assert resolver.resolve(None, "value") == "value" + assert resolver.resolve(None, 123) == "123" + assert resolver.resolve(None, None) == "" diff --git a/tests/core/formatting/test_style_resolver.py b/tests/core/formatting/test_style_resolver.py new file mode 100644 index 0000000..8b57f2d --- /dev/null +++ b/tests/core/formatting/test_style_resolver.py @@ -0,0 +1,143 @@ +import pytest + +from myfasthtml.core.formatting.dataclasses import Style +from myfasthtml.core.formatting.style_resolver import StyleResolver + + +class TestResolve: + def test_resolve_explicit_properties(self): + """Style with explicit properties only.""" + resolver = StyleResolver() + style = Style(background_color="red", color="white", font_weight="bold") + result = resolver.resolve(style) + + assert result["background-color"] == "red" + assert result["color"] == "white" + assert result["font-weight"] == "bold" + + def test_resolve_preset_with_override(self): + """Preset properties can be overridden by explicit values.""" + resolver = StyleResolver() + # "success" preset has background and color defined + style = Style(preset="success", color="black") + result = resolver.resolve(style) + + # background comes from preset + assert result["background-color"] == "var(--color-success)" + # color is overridden + assert result["color"] == "black" + + def test_resolve_unknown_preset_ignored(self): + """Unknown preset is ignored, only explicit properties returned.""" + resolver = StyleResolver() + style = Style(preset="unknown_preset", color="blue") + result = resolver.resolve(style) + + assert "background-color" not in result + assert result["color"] == "blue" + + def test_resolve_custom_presets(self): + """Custom presets can be provided to the resolver.""" + custom_presets = { + "custom": { + "background-color": "purple", + "color": "yellow", + } + } + resolver = StyleResolver(style_presets=custom_presets) + style = Style(preset="custom") + result = resolver.resolve(style) + + assert result["background-color"] == "purple" + assert result["color"] == "yellow" + + def test_resolve_empty_style(self): + """Empty style returns empty dict.""" + resolver = StyleResolver() + style = Style() + result = resolver.resolve(style) + + assert result == {} + + def test_resolve_none_style(self): + """None style returns empty dict.""" + resolver = StyleResolver() + result = resolver.resolve(None) + + assert result == {} + + def test_resolve_converts_property_names(self): + """Python attribute names are converted to CSS property names.""" + resolver = StyleResolver() + style = Style( + background_color="red", + font_weight="bold", + font_style="italic", + font_size="14px", + text_decoration="underline" + ) + result = resolver.resolve(style) + + assert "background-color" in result + assert "font-weight" in result + assert "font-style" in result + assert "font-size" in result + assert "text-decoration" in result + # Python names should not be in result + assert "background_color" not in result + assert "font_weight" not in result + + def test_resolve_all_properties(self): + """All style properties are resolved correctly.""" + resolver = StyleResolver() + style = Style( + background_color="#ff0000", + color="#ffffff", + font_weight="bold", + font_style="italic", + font_size="12px", + text_decoration="line-through" + ) + result = resolver.resolve(style) + + assert result["background-color"] == "#ff0000" + assert result["color"] == "#ffffff" + assert result["font-weight"] == "bold" + assert result["font-style"] == "italic" + assert result["font-size"] == "12px" + assert result["text-decoration"] == "line-through" + + +class TestToCssString: + def test_to_css_string(self): + """Convert resolved style to CSS inline string.""" + resolver = StyleResolver() + style = Style(background_color="red", color="white") + result = resolver.to_css_string(style) + + assert "background-color: red" in result + assert "color: white" in result + assert result.endswith(";") + + def test_to_css_string_empty(self): + """Empty style returns empty string.""" + resolver = StyleResolver() + style = Style() + result = resolver.to_css_string(style) + + assert result == "" + + def test_to_css_string_none(self): + """None style returns empty string.""" + resolver = StyleResolver() + result = resolver.to_css_string(None) + + assert result == "" + + def test_to_css_string_format(self): + """CSS string has correct format with semicolons.""" + resolver = StyleResolver() + style = Style(color="blue") + result = resolver.to_css_string(style) + + assert result == "color: blue;"