Compare commits
2 Commits
fc38196ad9
...
86b80b04f7
| Author | SHA1 | Date | |
|---|---|---|---|
| 86b80b04f7 | |||
| 8e059df68a |
1259
docs/DataGrid Formatting - User Guide.md
Normal file
1259
docs/DataGrid Formatting - User Guide.md
Normal file
File diff suppressed because it is too large
Load Diff
1437
docs/DataGrid Formatting System.md
Normal file
1437
docs/DataGrid Formatting System.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -203,7 +203,7 @@ def generate_formatting_dsl_mode() -> Dict[str, Any]:
|
|||||||
{"regex": r"#.*", "token": "comment"},
|
{"regex": r"#.*", "token": "comment"},
|
||||||
|
|
||||||
# Scope keywords
|
# Scope keywords
|
||||||
{"regex": r"\b(?:column|row|cell)\b", "token": "keyword"},
|
{"regex": r"\b(?:column|row|cell|table|tables)\b", "token": "keyword"},
|
||||||
|
|
||||||
# Condition keywords
|
# Condition keywords
|
||||||
{"regex": r"\b(?:if|not|and|or|in|between|case)\b", "token": "keyword"},
|
{"regex": r"\b(?:if|not|and|or|in|between|case)\b", "token": "keyword"},
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ GRAMMAR = r"""
|
|||||||
|
|
||||||
unary_comparison: operand "isempty" -> isempty_comp
|
unary_comparison: operand "isempty" -> isempty_comp
|
||||||
| operand "isnotempty" -> isnotempty_comp
|
| operand "isnotempty" -> isnotempty_comp
|
||||||
|
| operand "isnan" -> isnan_comp
|
||||||
|
|
||||||
case_modifier: "(" "case" ")"
|
case_modifier: "(" "case" ")"
|
||||||
|
|
||||||
@@ -122,6 +123,7 @@ GRAMMAR = r"""
|
|||||||
| "boolean" -> fmt_boolean
|
| "boolean" -> fmt_boolean
|
||||||
| "text" -> fmt_text
|
| "text" -> fmt_text
|
||||||
| "enum" -> fmt_enum
|
| "enum" -> fmt_enum
|
||||||
|
| "constant" -> fmt_constant
|
||||||
|
|
||||||
// ==================== Keyword arguments ====================
|
// ==================== Keyword arguments ====================
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ..dataclasses import (
|
|||||||
BooleanFormatter,
|
BooleanFormatter,
|
||||||
TextFormatter,
|
TextFormatter,
|
||||||
EnumFormatter,
|
EnumFormatter,
|
||||||
|
ConstantFormatter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ class DSLTransformer(Transformer):
|
|||||||
if isinstance(item, Style):
|
if isinstance(item, Style):
|
||||||
style_obj = item
|
style_obj = item
|
||||||
elif isinstance(item, (NumberFormatter, DateFormatter, BooleanFormatter,
|
elif isinstance(item, (NumberFormatter, DateFormatter, BooleanFormatter,
|
||||||
TextFormatter, EnumFormatter)):
|
TextFormatter, EnumFormatter, ConstantFormatter)):
|
||||||
formatter_obj = item
|
formatter_obj = item
|
||||||
elif isinstance(item, Condition):
|
elif isinstance(item, Condition):
|
||||||
condition_obj = item
|
condition_obj = item
|
||||||
@@ -166,9 +167,12 @@ class DSLTransformer(Transformer):
|
|||||||
|
|
||||||
def isempty_comp(self, items):
|
def isempty_comp(self, items):
|
||||||
return Condition(operator="isempty")
|
return Condition(operator="isempty")
|
||||||
|
|
||||||
def isnotempty_comp(self, items):
|
def isnotempty_comp(self, items):
|
||||||
return Condition(operator="isnotempty")
|
return Condition(operator="isnotempty")
|
||||||
|
|
||||||
|
def isnan_comp(self, items):
|
||||||
|
return Condition(operator="isnan")
|
||||||
|
|
||||||
# ==================== Operators ====================
|
# ==================== Operators ====================
|
||||||
|
|
||||||
@@ -333,9 +337,12 @@ class DSLTransformer(Transformer):
|
|||||||
|
|
||||||
def fmt_text(self, items):
|
def fmt_text(self, items):
|
||||||
return "text"
|
return "text"
|
||||||
|
|
||||||
def fmt_enum(self, items):
|
def fmt_enum(self, items):
|
||||||
return "enum"
|
return "enum"
|
||||||
|
|
||||||
|
def fmt_constant(self, items):
|
||||||
|
return "constant"
|
||||||
|
|
||||||
def _build_formatter(self, format_type: str, kwargs: dict):
|
def _build_formatter(self, format_type: str, kwargs: dict):
|
||||||
"""Build the appropriate Formatter subclass."""
|
"""Build the appropriate Formatter subclass."""
|
||||||
@@ -349,6 +356,8 @@ class DSLTransformer(Transformer):
|
|||||||
return TextFormatter(**self._filter_text_kwargs(kwargs))
|
return TextFormatter(**self._filter_text_kwargs(kwargs))
|
||||||
elif format_type == "enum":
|
elif format_type == "enum":
|
||||||
return EnumFormatter(**self._filter_enum_kwargs(kwargs))
|
return EnumFormatter(**self._filter_enum_kwargs(kwargs))
|
||||||
|
elif format_type == "constant":
|
||||||
|
return ConstantFormatter(**self._filter_constant_kwargs(kwargs))
|
||||||
else:
|
else:
|
||||||
raise DSLValidationError(f"Unknown formatter type: {format_type}")
|
raise DSLValidationError(f"Unknown formatter type: {format_type}")
|
||||||
|
|
||||||
@@ -376,7 +385,12 @@ class DSLTransformer(Transformer):
|
|||||||
"""Filter kwargs for EnumFormatter."""
|
"""Filter kwargs for EnumFormatter."""
|
||||||
valid_keys = {"source", "default", "allow_empty", "empty_label", "order_by"}
|
valid_keys = {"source", "default", "allow_empty", "empty_label", "order_by"}
|
||||||
return {k: v for k, v in kwargs.items() if k in valid_keys}
|
return {k: v for k, v in kwargs.items() if k in valid_keys}
|
||||||
|
|
||||||
|
def _filter_constant_kwargs(self, kwargs: dict) -> dict:
|
||||||
|
"""Filter kwargs for ConstantFormatter."""
|
||||||
|
valid_keys = {"value"}
|
||||||
|
return {k: v for k, v in kwargs.items() if k in valid_keys}
|
||||||
|
|
||||||
# ==================== Keyword arguments ====================
|
# ==================== Keyword arguments ====================
|
||||||
|
|
||||||
def kwargs(self, items):
|
def kwargs(self, items):
|
||||||
|
|||||||
@@ -71,23 +71,22 @@ class FormattingEngine:
|
|||||||
if not matching_rules:
|
if not matching_rules:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# Resolve conflicts to get the winning rule
|
# Resolve style and formatter independently
|
||||||
winning_rule = self._resolve_conflicts(matching_rules)
|
# This allows combining style from one rule and formatter from another
|
||||||
|
winning_style = self._resolve_style(matching_rules)
|
||||||
if winning_rule is None:
|
winning_formatter = self._resolve_formatter(matching_rules)
|
||||||
return None, None
|
|
||||||
|
|
||||||
# Apply style
|
# Apply style
|
||||||
css_string = None
|
css_string = None
|
||||||
if winning_rule.style:
|
if winning_style:
|
||||||
css_string = self._style_resolver.to_css_string(winning_rule.style)
|
css_string = self._style_resolver.to_css_string(winning_style)
|
||||||
if css_string == "":
|
if css_string == "":
|
||||||
css_string = None
|
css_string = None
|
||||||
|
|
||||||
# Apply formatter
|
# Apply formatter
|
||||||
formatted_value = None
|
formatted_value = None
|
||||||
if winning_rule.formatter:
|
if winning_formatter:
|
||||||
formatted_value = self._formatter_resolver.resolve(winning_rule.formatter, cell_value)
|
formatted_value = self._formatter_resolver.resolve(winning_formatter, cell_value)
|
||||||
|
|
||||||
return css_string, formatted_value
|
return css_string, formatted_value
|
||||||
|
|
||||||
@@ -116,10 +115,89 @@ class FormattingEngine:
|
|||||||
|
|
||||||
return matching
|
return matching
|
||||||
|
|
||||||
|
def _resolve_style(self, matching_rules: list[FormatRule]):
|
||||||
|
"""
|
||||||
|
Resolve style conflicts when multiple rules match.
|
||||||
|
|
||||||
|
Resolution logic:
|
||||||
|
1. Filter to rules that have a style
|
||||||
|
2. Specificity = 1 if rule has condition, 0 otherwise
|
||||||
|
3. Higher specificity wins
|
||||||
|
4. At equal specificity, last rule wins
|
||||||
|
|
||||||
|
Args:
|
||||||
|
matching_rules: List of rules that matched
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The winning Style, or None if no rules have style
|
||||||
|
"""
|
||||||
|
# Filter to rules with style
|
||||||
|
style_rules = [rule for rule in matching_rules if rule.style is not None]
|
||||||
|
|
||||||
|
if not style_rules:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(style_rules) == 1:
|
||||||
|
return style_rules[0].style
|
||||||
|
|
||||||
|
# Calculate specificity for each rule
|
||||||
|
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 style_rules)
|
||||||
|
|
||||||
|
# Filter to rules with max specificity
|
||||||
|
top_rules = [rule for rule in style_rules if get_specificity(rule) == max_specificity]
|
||||||
|
|
||||||
|
# Last rule wins among equal specificity
|
||||||
|
return top_rules[-1].style
|
||||||
|
|
||||||
|
def _resolve_formatter(self, matching_rules: list[FormatRule]):
|
||||||
|
"""
|
||||||
|
Resolve formatter conflicts when multiple rules match.
|
||||||
|
|
||||||
|
Resolution logic:
|
||||||
|
1. Filter to rules that have a formatter
|
||||||
|
2. Specificity = 1 if rule has condition, 0 otherwise
|
||||||
|
3. Higher specificity wins
|
||||||
|
4. At equal specificity, last rule wins
|
||||||
|
|
||||||
|
Args:
|
||||||
|
matching_rules: List of rules that matched
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The winning Formatter, or None if no rules have formatter
|
||||||
|
"""
|
||||||
|
# Filter to rules with formatter
|
||||||
|
formatter_rules = [rule for rule in matching_rules if rule.formatter is not None]
|
||||||
|
|
||||||
|
if not formatter_rules:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(formatter_rules) == 1:
|
||||||
|
return formatter_rules[0].formatter
|
||||||
|
|
||||||
|
# Calculate specificity for each rule
|
||||||
|
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 formatter_rules)
|
||||||
|
|
||||||
|
# Filter to rules with max specificity
|
||||||
|
top_rules = [rule for rule in formatter_rules if get_specificity(rule) == max_specificity]
|
||||||
|
|
||||||
|
# Last rule wins among equal specificity
|
||||||
|
return top_rules[-1].formatter
|
||||||
|
|
||||||
def _resolve_conflicts(self, matching_rules: list[FormatRule]) -> FormatRule | None:
|
def _resolve_conflicts(self, matching_rules: list[FormatRule]) -> FormatRule | None:
|
||||||
"""
|
"""
|
||||||
Resolve conflicts when multiple rules match.
|
Resolve conflicts when multiple rules match.
|
||||||
|
|
||||||
|
DEPRECATED: This method is kept for backward compatibility but is no longer used.
|
||||||
|
Use _resolve_style() and _resolve_formatter() instead.
|
||||||
|
|
||||||
Resolution logic:
|
Resolution logic:
|
||||||
1. Specificity = 1 if rule has condition, 0 otherwise
|
1. Specificity = 1 if rule has condition, 0 otherwise
|
||||||
2. Higher specificity wins
|
2. Higher specificity wins
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from myfasthtml.core.formatting.dataclasses import (
|
|||||||
BooleanFormatter,
|
BooleanFormatter,
|
||||||
TextFormatter,
|
TextFormatter,
|
||||||
EnumFormatter,
|
EnumFormatter,
|
||||||
|
ConstantFormatter,
|
||||||
)
|
)
|
||||||
from myfasthtml.core.formatting.dsl import (
|
from myfasthtml.core.formatting.dsl import (
|
||||||
parse_dsl,
|
parse_dsl,
|
||||||
@@ -342,11 +343,23 @@ column status:
|
|||||||
format.enum(source={"draft": "Brouillon", "published": "Publie"})
|
format.enum(source={"draft": "Brouillon", "published": "Publie"})
|
||||||
"""
|
"""
|
||||||
rules = parse_dsl(dsl)
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
formatter = rules[0].rule.formatter
|
formatter = rules[0].rule.formatter
|
||||||
assert isinstance(formatter, EnumFormatter)
|
assert isinstance(formatter, EnumFormatter)
|
||||||
assert formatter.source == {"draft": "Brouillon", "published": "Publie"}
|
assert formatter.source == {"draft": "Brouillon", "published": "Publie"}
|
||||||
|
|
||||||
|
def test_i_can_parse_format_constant(self):
|
||||||
|
"""Test parsing format.constant with fixed value."""
|
||||||
|
dsl = """
|
||||||
|
column placeholder:
|
||||||
|
format.constant(value="N/A")
|
||||||
|
"""
|
||||||
|
rules = parse_dsl(dsl)
|
||||||
|
|
||||||
|
formatter = rules[0].rule.formatter
|
||||||
|
assert isinstance(formatter, ConstantFormatter)
|
||||||
|
assert formatter.value == "N/A"
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Condition Tests
|
# Condition Tests
|
||||||
@@ -379,9 +392,9 @@ column amount:
|
|||||||
assert condition is not None
|
assert condition is not None
|
||||||
assert condition.operator == operator
|
assert condition.operator == operator
|
||||||
|
|
||||||
@pytest.mark.parametrize("unary_op", ["isempty", "isnotempty"])
|
@pytest.mark.parametrize("unary_op", ["isempty", "isnotempty", "isnan"])
|
||||||
def test_i_can_parse_unary_conditions(self, unary_op):
|
def test_i_can_parse_unary_conditions(self, unary_op):
|
||||||
"""Test parsing unary conditions (isempty, isnotempty)."""
|
"""Test parsing unary conditions (isempty, isnotempty, isnan)."""
|
||||||
dsl = f"""
|
dsl = f"""
|
||||||
column name:
|
column name:
|
||||||
style("neutral") if value {unary_op}
|
style("neutral") if value {unary_op}
|
||||||
|
|||||||
@@ -215,6 +215,75 @@ class TestConflictResolution:
|
|||||||
|
|
||||||
assert "color: red" in css
|
assert "color: red" in css
|
||||||
|
|
||||||
|
def test_style_and_formatter_fusion(self):
|
||||||
|
"""
|
||||||
|
Test that style and formatter can come from different rules.
|
||||||
|
|
||||||
|
Scenario:
|
||||||
|
- Rule 1: format("EUR") - unconditional formatter
|
||||||
|
- Rule 2: style("secondary") if value > col.X - conditional style
|
||||||
|
|
||||||
|
When condition is met:
|
||||||
|
- Style from Rule 2 (higher specificity for style)
|
||||||
|
- Formatter from Rule 1 (only rule with formatter)
|
||||||
|
- Both should be applied (fusion)
|
||||||
|
"""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(formatter=NumberFormatter(precision=2, suffix=" €")), # unconditional
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator=">", value={"col": "budget"}),
|
||||||
|
style=Style(preset="secondary") # conditional
|
||||||
|
),
|
||||||
|
]
|
||||||
|
row_data = {"budget": 100}
|
||||||
|
|
||||||
|
# Case 1: Condition met (value > budget)
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=150, row_data=row_data)
|
||||||
|
|
||||||
|
assert css is not None
|
||||||
|
assert "var(--color-secondary)" in css # Style from Rule 2
|
||||||
|
assert formatted == "150.00 €" # Formatter from Rule 1
|
||||||
|
|
||||||
|
# Case 2: Condition not met (value <= budget)
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=50, row_data=row_data)
|
||||||
|
|
||||||
|
assert css is None # No style (Rule 2 doesn't match)
|
||||||
|
assert formatted == "50.00 €" # Formatter from Rule 1 still applies
|
||||||
|
|
||||||
|
def test_multiple_styles_and_formatters_highest_specificity_wins(self):
|
||||||
|
"""
|
||||||
|
Test that style and formatter are resolved independently with specificity.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Rule 1: style("neutral") - unconditional
|
||||||
|
- Rule 2: format("EUR") - unconditional
|
||||||
|
- Rule 3: style("error") if value < 0 - conditional style
|
||||||
|
- Rule 4: format.number(precision=0) if value < 0 - conditional formatter
|
||||||
|
|
||||||
|
When value < 0:
|
||||||
|
- Style from Rule 3 (higher specificity)
|
||||||
|
- Formatter from Rule 4 (higher specificity)
|
||||||
|
"""
|
||||||
|
engine = FormattingEngine()
|
||||||
|
rules = [
|
||||||
|
FormatRule(style=Style(preset="neutral")),
|
||||||
|
FormatRule(formatter=NumberFormatter(precision=2, suffix=" €")),
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=0),
|
||||||
|
style=Style(preset="error")
|
||||||
|
),
|
||||||
|
FormatRule(
|
||||||
|
condition=Condition(operator="<", value=0),
|
||||||
|
formatter=NumberFormatter(precision=0, suffix=" €")
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
css, formatted = engine.apply_format(rules, cell_value=-5.67)
|
||||||
|
|
||||||
|
assert "var(--color-error)" in css # Rule 3 wins for style
|
||||||
|
assert formatted == "-6 €" # Rule 4 wins for formatter (precision=0)
|
||||||
|
|
||||||
|
|
||||||
class TestWithRowData:
|
class TestWithRowData:
|
||||||
def test_condition_with_column_reference(self):
|
def test_condition_with_column_reference(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user