Added "table" and "tables" in the DSL

This commit is contained in:
2026-02-07 22:48:51 +01:00
parent 08c8c00e28
commit 6160e91665
18 changed files with 717 additions and 54 deletions

View File

@@ -48,12 +48,14 @@ Rules are indented (Python-style) under their scope.
Scopes define which cells a rule applies to:
| Scope | Syntax | Applies To |
|-------|--------|------------|
| **Column** | `column <name>:` | All cells in the column |
| **Row** | `row <index>:` | All cells in the row |
| **Cell (coordinates)** | `cell (<col>, <row>):` | Single cell by position |
| **Cell (ID)** | `cell <cell_id>:` | Single cell by ID |
| Scope | Syntax | Applies To | Specificity |
|-------|--------|------------|-------------|
| **Cell (coordinates)** | `cell (<col>, <row>):` | Single cell by position | Highest (1) |
| **Cell (ID)** | `cell <cell_id>:` | Single cell by ID | Highest (1) |
| **Row** | `row <index>:` | All cells in the row | High (2) |
| **Column** | `column <name>:` | All cells in the column | Medium (3) |
| **Table** | `table "<name>":` | All cells in a specific table | Low (4) |
| **Tables** | `tables:` | All cells in all tables (global) | Lowest (5) |
**Column scope:**
@@ -97,6 +99,24 @@ cell tcell_grid1-3-2:
style(background_color="yellow")
```
**Table scope:**
```python
# Table by name (must match DataGrid _settings.name)
table "products":
style("neutral")
format.number(precision=2)
```
**Tables scope (global):**
```python
# All tables in the application
tables:
style(color="#333")
format.date(format="%Y-%m-%d")
```
### Rules
A rule consists of optional **style**, optional **format**, and optional **condition**:
@@ -355,10 +375,12 @@ program : scope+
// Scopes
scope : scope_header NEWLINE INDENT rule+ DEDENT
scope_header : column_scope | row_scope | cell_scope
scope_header : column_scope | row_scope | cell_scope | table_scope | tables_scope
column_scope : "column" column_name ":"
row_scope : "row" INTEGER ":"
cell_scope : "cell" cell_ref ":"
table_scope : "table" QUOTED_STRING ":"
tables_scope : "tables" ":"
column_name : NAME | QUOTED_STRING
cell_ref : "(" column_name "," INTEGER ")" | CELL_ID
@@ -452,6 +474,21 @@ row 0:
style("neutral", bold=True)
```
**Apply default style to all cells in a table:**
```python
table "products":
style("neutral")
format.number(precision=2)
```
**Apply global styles to all tables:**
```python
tables:
style(font_size="14px")
```
### Advanced Examples
**Compare with another column:**
@@ -496,9 +533,17 @@ column status:
format.enum(source={"draft": "Brouillon", "pending": "En attente", "approved": "Approuvé"}, default="Inconnu")
```
**Complete example - Financial report:**
**Complete example - Financial report with hierarchy:**
```python
# Global styling for all tables
tables:
style(font_size="14px", color="#333")
# Table-specific defaults
table "financial_report":
format.number(precision=2)
# Header styling
row 0:
style("neutral", bold=True)
@@ -533,6 +578,13 @@ cell (amount, 10):
style("accent", bold=True)
```
**Note on hierarchy:** In the example above, for the cell `(amount, 10)`, the styles are applied in this order:
1. Cell-specific rule wins (accent, bold)
2. If no cell rule, column rules apply (amount formatting + conditional styles)
3. If no column rule, row rules apply (header bold)
4. If no row rule, table rules apply (financial_report precision)
5. If no table rule, global rules apply (tables font size and color)
---
## Autocompletion
@@ -579,8 +631,9 @@ The DSL editor provides context-aware autocompletion to help users write rules e
| Context | Trigger | Suggestions |
|---------|---------|-------------|
| **Scope keyword** | Start of line | `column`, `row`, `cell` |
| **Scope keyword** | Start of line | `column`, `row`, `cell`, `table`, `tables` |
| **Column name** | After `column ` | Column names from DataGrid |
| **Table name** | After `table ` | Table name from current DataGrid (_settings.name) |
| **Style preset** | Inside `style("` | Style presets |
| **Style parameter** | Inside `style(... , ` | `bold`, `italic`, `color`, etc. |
| **Format preset** | Inside `format("` | Format presets |
@@ -609,6 +662,13 @@ column amount: │ (new line, indent)
style("error", bold=True) if │ value, col., row., not
style("error", bold=True) if value │ ==, !=, <, >, in, ...
style("error", bold=True) if value < │ [number input]
tab │ table, tables
table │ [table name from current grid]
table "products": │ (new line, indent)
tables │ tables
tables: │ (new line, indent)
```
---
@@ -1166,12 +1226,14 @@ class DatagridMetadataProvider(Protocol):
| Context | Trigger | Suggestions |
|---------|---------|-------------|
| `SCOPE_KEYWORD` | Start of non-indented line | `column`, `row`, `cell` |
| `SCOPE_KEYWORD` | Start of non-indented line | `column`, `row`, `cell`, `table`, `tables` |
| `COLUMN_NAME` | After `column ` | Column names from DataGrid |
| `ROW_INDEX` | After `row ` | First 10 indices + last index |
| `CELL_START` | After `cell ` | `(` |
| `CELL_COLUMN` | After `cell (` | Column names |
| `CELL_ROW` | After `cell (col, ` | First 10 indices + last index |
| `TABLE_NAME` | After `table ` | Table name from current DataGrid (_settings.name) |
| `TABLES_SCOPE` | After `tables` | `:` |
| `RULE_START` | Start of indented line (after scope) | `style(`, `format(`, `format.` |
| `STYLE_ARGS` | After `style(` (no quote) | Presets with quotes + named params (`bold=`, `color=`, ...) |
| `STYLE_PRESET` | Inside `style("` | Style presets |
@@ -1313,16 +1375,18 @@ The following features are excluded from autocompletion for simplicity:
| DSL Grammar (lark) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/grammar.py` |
| DSL Parser | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/parser.py` |
| DSL Transformer | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/transformer.py` |
| Scope Dataclasses | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/scopes.py` |
| Scope Dataclasses (Column, Row, Cell) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/scopes.py` |
| Scope Dataclasses (Table, Tables) | :o: To implement | `src/myfasthtml/core/formatting/dsl/scopes.py` |
| Exceptions | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/exceptions.py` |
| Public API (`parse_dsl()`) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/__init__.py` |
| Unit Tests (Parser) | :white_check_mark: ~35 tests | `tests/core/formatting/dsl/test_dsl_parser.py` |
| **Autocompletion** | | |
| DatagridMetadataProvider | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/provider.py` |
| Scope Detector | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
| Context Detector | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
| Suggestions Generator | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/suggestions.py` |
| Completion Engine | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/engine.py` |
| Scope Detector (Column, Row, Cell) | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
| Scope Detector (Table, Tables) | :o: To implement | `src/myfasthtml/core/formatting/dsl/completion/contexts.py` |
| Context Detector | :white_check_mark: Implemented | `FormattingCompletionEngine.py` |
| Suggestions Generator | :white_check_mark: Implemented | `FormattingCompletionEngine.py` |
| Completion Engine | :white_check_mark: Implemented | `FormattingCompletionEngine.py` |
| Presets | :white_check_mark: Implemented | `src/myfasthtml/core/formatting/dsl/completion/presets.py` |
| Unit Tests (Completion) | :white_check_mark: ~50 tests | `tests/core/formatting/dsl/test_completion.py` |
| REST Endpoint | :white_check_mark: Implemented | `src/myfasthtml/core/utils.py``/myfasthtml/completions` |

View File

@@ -16,6 +16,12 @@
| `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 | |
| **Scope Levels** | | |
| Cell scope | :white_check_mark: Implemented | |
| Row scope | :white_check_mark: Implemented | |
| Column scope | :white_check_mark: Implemented | |
| Table scope | :o: To implement | |
| Tables scope (global) | :o: To implement | |
| **DataGrid Integration** | | |
| Integration in `mk_body_cell_content()` | :white_check_mark: Implemented | `DataGrid.py` |
| DataGridFormattingEditor | :white_check_mark: Implemented | `DataGridFormattingEditor.py` |
@@ -32,13 +38,15 @@
This document describes the formatting capabilities for the DataGrid component.
**Formatting applies at three levels:**
**Formatting applies at five levels:**
| Level | Cells Targeted | Condition Evaluated On |
|------------|-------------------------|----------------------------------------------|
| **Cell** | 1 specific cell | The cell value |
| **Row** | All cells in the row | Each cell value (or fixed column with `col`) |
| **Column** | All cells in the column | Each cell value (or fixed row with `row`) |
| Level | Cells Targeted | Condition Evaluated On | Specificity |
|------------|-----------------------------------|----------------------------------------------|-------------|
| **Cell** | 1 specific cell | The cell value | Highest (1) |
| **Row** | All cells in the row | Each cell value (or fixed column with `col`) | High (2) |
| **Column** | All cells in the column | Each cell value (or fixed row with `row`) | Medium (3) |
| **Table** | All cells in a specific table | Each cell value | Low (4) |
| **Tables** | All cells in all tables (global) | Each cell value | Lowest (5) |
---
@@ -468,10 +476,12 @@ formatter_presets = {
### Format Storage Location
| Level | Storage | Key | Status |
|------------|------------------------------|---------|--------|
| **Column** | `DataGridColumnState.format` | - | Structure exists |
| **Row** | `DataGridRowState.format` | - | Structure exists |
|------------|--------------------------------|---------|--------|
| **Cell** | `DatagridState.cell_formats` | Cell ID | Structure exists |
| **Row** | `DataGridRowState.format` | - | Structure exists |
| **Column** | `DataGridColumnState.format` | - | Structure exists |
| **Table** | `DatagridState.table_format` | - | :o: To implement |
| **Tables** | `DataGridsManager.all_tables_formats` | - | :o: To implement |
### Cell ID Format

View File

@@ -78,6 +78,7 @@ class DatagridState(DbObject):
self.edition: DatagridEditionState = DatagridEditionState()
self.selection: DatagridSelectionState = DatagridSelectionState()
self.cell_formats: dict = {}
self.table_format: list = []
self.ne_df = None
self.ns_fast_access = None
@@ -274,7 +275,7 @@ class DataGrid(MultipleInstance):
def _get_filtered_df(self):
if self._df is None:
return DataFrame()
return None
df = self._df.copy()
df = self._apply_sort(df) # need to keep the real type to sort
@@ -396,6 +397,8 @@ class DataGrid(MultipleInstance):
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
4. Table-level: self._state.table_format
5. Tables-level (global): manager.all_tables_formats
Args:
col_pos: Column position index
@@ -419,7 +422,11 @@ class DataGrid(MultipleInstance):
if col_def.format:
return col_def.format
return None
if self._state.table_format:
return self._state.table_format
# Get global tables formatting from manager
return self._parent.all_tables_formats
def set_column_width(self, col_id: str, width: str):
"""Update column width after resize. Called via Command from JS."""
@@ -629,6 +636,9 @@ class DataGrid(MultipleInstance):
OPTIMIZED: Uses OptimizedDiv for rows instead of Div for faster rendering.
"""
df = self._get_filtered_df()
if df is None:
return []
start = page_index * DATAGRID_PAGE_SIZE
end = start + DATAGRID_PAGE_SIZE
if self._state.ns_total_rows > end:

View File

@@ -2,7 +2,8 @@ import logging
from collections import defaultdict
from myfasthtml.controls.DslEditor import DslEditor
from myfasthtml.core.formatting.dsl import parse_dsl, DSLSyntaxError, ColumnScope, RowScope, CellScope
from myfasthtml.core.formatting.dsl import parse_dsl, DSLSyntaxError, ColumnScope, RowScope, CellScope, TableScope, TablesScope
from myfasthtml.core.instances import InstancesManager
logger = logging.getLogger("DataGridFormattingEditor")
@@ -62,6 +63,8 @@ class DataGridFormattingEditor(DslEditor):
columns_rules = defaultdict(list) # key = column name
rows_rules = defaultdict(list) # key = row index
cells_rules = defaultdict(list) # key = cell_id
table_rules = [] # rules for this table
tables_rules = [] # global rules for all tables
for scoped_rule in scoped_rules:
scope = scoped_rule.scope
@@ -75,6 +78,14 @@ class DataGridFormattingEditor(DslEditor):
cell_id = self._get_cell_id(scope)
if cell_id:
cells_rules[cell_id].append(rule)
elif isinstance(scope, TableScope):
# Validate table name matches current grid
if scope.table == self._parent._settings.name:
table_rules.append(rule)
else:
logger.warning(f"Table name '{scope.table}' does not match grid name '{self._parent._settings.name}', skipping rules")
elif isinstance(scope, TablesScope):
tables_rules.append(rule)
# Step 3: Copy state for atomic update
state = self._parent.get_state().copy()
@@ -85,6 +96,7 @@ class DataGridFormattingEditor(DslEditor):
for row in state.rows:
row.format = None
state.cell_formats.clear()
state.table_format = []
# Step 5: Apply grouped rules on the copy
for column_name, rules in columns_rules.items():
@@ -104,9 +116,20 @@ class DataGridFormattingEditor(DslEditor):
for cell_id, rules in cells_rules.items():
state.cell_formats[cell_id] = rules
# Apply table-level rules
if table_rules:
state.table_format = table_rules
# Apply global tables-level rules to manager
if tables_rules:
from myfasthtml.controls.DataGridsManager import DataGridsManager
manager = InstancesManager.get_by_type(self._session, DataGridsManager)
if manager:
manager.all_tables_formats = tables_rules
# Step 6: Update state atomically
self._parent.get_state().update(state)
# Step 7: Refresh the DataGrid
logger.debug(f"Formatting applied: {len(columns_rules)} columns, {len(rows_rules)} rows, {len(cells_rules)} cells")
logger.debug(f"Formatting applied: {len(columns_rules)} columns, {len(rows_rules)} rows, {len(cells_rules)} cells, table: {len(table_rules)}, tables: {len(tables_rules)}")
return self._parent.render_partial("body")

View File

@@ -84,12 +84,13 @@ class DataGridsManager(SingleInstance, DatagridMetadataProvider):
self._state = DataGridsState(self)
self._tree = self._mk_tree()
self._tree.bind_command("SelectNode", self.commands.show_document())
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager)
self._tabs_manager = InstancesManager.get_by_type(self._session, TabsManager, None)
self._registry = DataGridsRegistry(parent)
# Global presets shared across all DataGrids
self.style_presets: dict = DEFAULT_STYLE_PRESETS.copy()
self.formatter_presets: dict = DEFAULT_FORMATTER_PRESETS.copy()
self.all_tables_formats: list = []
def upload_from_source(self):
file_upload = FileUpload(self)

View File

@@ -99,7 +99,7 @@ class DslEditor(MultipleInstance):
self._dsl = dsl
self.conf = conf or DslEditorConf()
self._state = DslEditorState(self, name=conf.name, save_state=save_state)
self._state = DslEditorState(self, name=self.conf.name, save_state=save_state)
self.commands = Commands(self)
logger.debug(f"DslEditor created with id={self._id}, dsl={dsl.name}")

View File

@@ -23,7 +23,7 @@ Example:
"""
from .parser import get_parser
from .transformer import DSLTransformer
from .scopes import ColumnScope, RowScope, CellScope, ScopedRule
from .scopes import ColumnScope, RowScope, CellScope, TableScope, TablesScope, ScopedRule
from .exceptions import DSLError, DSLSyntaxError, DSLValidationError
@@ -61,6 +61,8 @@ __all__ = [
"ColumnScope",
"RowScope",
"CellScope",
"TableScope",
"TablesScope",
"ScopedRule",
# Exceptions
"DSLError",

View File

@@ -106,6 +106,12 @@ class FormattingCompletionEngine(BaseCompletionEngine):
case Context.CELL_ROW:
return self._get_row_index_suggestions()
case Context.TABLE_NAME:
return self._get_table_name_suggestion()
case Context.TABLES_SCOPE:
return [Suggestion(":", "Define global rules for all tables", "syntax")]
# =================================================================
# Rule-level contexts
# =================================================================
@@ -231,6 +237,12 @@ class FormattingCompletionEngine(BaseCompletionEngine):
pass
return []
def _get_table_name_suggestion(self) -> list[Suggestion]:
"""Get table name suggestion (current table only)."""
if self.table_name:
return [Suggestion(f'"{self.table_name}"', f"Current table: {self.table_name}", "table")]
return []
def _get_style_preset_suggestions(self) -> list[Suggestion]:
"""Get style preset suggestions (without quotes)."""
suggestions = []

View File

@@ -25,12 +25,14 @@ class Context(Enum):
NONE = auto()
# Scope-level contexts
SCOPE_KEYWORD = auto() # Start of non-indented line: column, row, cell
SCOPE_KEYWORD = auto() # Start of non-indented line: column, row, cell, table, tables
COLUMN_NAME = auto() # After "column ": column names
ROW_INDEX = auto() # After "row ": row indices
CELL_START = auto() # After "cell ": (
CELL_COLUMN = auto() # After "cell (": column names
CELL_ROW = auto() # After "cell (col, ": row indices
TABLE_NAME = auto() # After "table ": table name
TABLES_SCOPE = auto() # After "tables": colon
# Rule-level contexts
RULE_START = auto() # Start of indented line: style(, format(, format.
@@ -76,10 +78,10 @@ class DetectedScope:
Represents the detected scope from scanning previous lines.
Attributes:
scope_type: "column", "row", "cell", or None
scope_type: "column", "row", "cell", "table", "tables", or None
column_name: Column name (for column and cell scopes)
row_index: Row index (for row and cell scopes)
table_name: DataGrid name (if determinable)
table_name: Table name (for table scope) or DataGrid name
"""
scope_type: str | None = None
@@ -92,7 +94,7 @@ def detect_scope(text: str, current_line: int) -> DetectedScope:
"""
Detect the current scope by scanning backwards from the cursor line.
Looks for the most recent scope declaration (column/row/cell)
Looks for the most recent scope declaration (column/row/cell/table/tables)
that is not indented.
Args:
@@ -139,6 +141,17 @@ def detect_scope(text: str, current_line: int) -> DetectedScope:
scope_type="cell", column_name=column_name, row_index=row_index
)
# Check for table scope
match = re.match(r'^table\s+"([^"]+)"\s*:', line)
if match:
table_name = match.group(1)
return DetectedScope(scope_type="table", table_name=table_name)
# Check for tables scope
match = re.match(r"^tables\s*:", line)
if match:
return DetectedScope(scope_type="tables")
return DetectedScope()
@@ -192,6 +205,14 @@ def detect_context(text: str, cursor: Position, scope: DetectedScope) -> Context
if re.match(r'^cell\s+\(\s*(?:"[^"]*"|[a-zA-Z_][a-zA-Z0-9_]*)\s*,\s*$', line_to_cursor):
return Context.CELL_ROW
# After "table "
if re.match(r"^table\s+", line_to_cursor) and not line_to_cursor.rstrip().endswith(":"):
return Context.TABLE_NAME
# After "tables"
if re.match(r"^tables\s*$", line_to_cursor):
return Context.TABLES_SCOPE
# Start of line or partial keyword
return Context.SCOPE_KEYWORD

View File

@@ -120,6 +120,8 @@ SCOPE_KEYWORDS: list[Suggestion] = [
Suggestion("column", "Define column scope", "keyword"),
Suggestion("row", "Define row scope", "keyword"),
Suggestion("cell", "Define cell scope", "keyword"),
Suggestion("table", "Define table scope", "keyword"),
Suggestion("tables", "Define global scope for all tables", "keyword"),
]
# =============================================================================

View File

@@ -16,10 +16,14 @@ GRAMMAR = r"""
scope_header: column_scope
| row_scope
| cell_scope
| table_scope
| tables_scope
column_scope: "column" column_name
row_scope: "row" INTEGER
cell_scope: "cell" cell_ref
table_scope: "table" QUOTED_STRING
tables_scope: "tables"
column_name: NAME -> name
| QUOTED_STRING -> quoted_name

View File

@@ -67,7 +67,13 @@ class DSLParser:
# Strip leading whitespace/newlines and ensure text ends with newline
text = text.strip()
if text and not text.endswith("\n"):
# Handle empty text (return empty tree that will transform to empty list)
if not text:
from lark import Tree
return Tree('start', [])
if not text.endswith("\n"):
text += "\n"
try:

View File

@@ -32,6 +32,18 @@ class CellScope:
cell_id: str = None
@dataclass
class TableScope:
"""Scope targeting a specific table by name."""
table: str
@dataclass
class TablesScope:
"""Scope targeting all tables (global)."""
pass
@dataclass
class ScopedRule:
"""
@@ -40,8 +52,8 @@ class ScopedRule:
The DSL parser returns a list of ScopedRule objects.
Attributes:
scope: Where the rule applies (ColumnScope, RowScope, or CellScope)
scope: Where the rule applies (ColumnScope, RowScope, CellScope, TableScope, or TablesScope)
rule: The FormatRule (condition + style + formatter)
"""
scope: ColumnScope | RowScope | CellScope
scope: ColumnScope | RowScope | CellScope | TableScope | TablesScope
rule: FormatRule

View File

@@ -6,7 +6,7 @@ Converts lark AST into FormatRule and ScopedRule objects.
from lark import Transformer
from .exceptions import DSLValidationError
from .scopes import ColumnScope, RowScope, CellScope, ScopedRule
from .scopes import ColumnScope, RowScope, CellScope, TableScope, TablesScope, ScopedRule
from ..dataclasses import (
Condition,
Style,
@@ -68,6 +68,13 @@ class DSLTransformer(Transformer):
cell_id = str(items[0])
return CellScope(cell_id=cell_id)
def table_scope(self, items):
table_name = self._unquote(items[0])
return TableScope(table=table_name)
def tables_scope(self, items):
return TablesScope()
def name(self, items):
return str(items[0])

View File

@@ -202,12 +202,17 @@ class InstancesManager:
return default
@staticmethod
def get_by_type(session: dict, cls: type):
def get_by_type(session: dict, cls: type, default=NO_DEFAULT_VALUE):
session_id = InstancesManager.get_session_id(session)
res = [i for s, i in InstancesManager.instances.items() if s[0] == session_id and isinstance(i, cls)]
assert len(res) <= 1, f"Multiple instances of type {cls.__name__} found"
try:
assert len(res) > 0, f"No instance of type {cls.__name__} found"
return res[0]
except AssertionError:
if default is NO_DEFAULT_VALUE:
raise
return default
@staticmethod
def dynamic_get(session, component_parent: tuple, component: tuple):

View File

@@ -0,0 +1,279 @@
"""
Tests for DataGrid formatting integration with table/tables scopes.
Tests the complete formatting flow: DSL → Storage → Application.
"""
import pytest
from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
from myfasthtml.controls.DataGridsManager import DataGridsManager
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowState
from myfasthtml.core.constants import ColumnType
from myfasthtml.core.formatting.dataclasses import FormatRule, Style
from myfasthtml.core.formatting.dsl.definition import FormattingDSL
from myfasthtml.core.instances import InstancesManager
@pytest.fixture
def manager(root_instance):
"""Create a DataGridsManager instance."""
mgr = DataGridsManager(root_instance, _id="test-manager")
yield mgr
InstancesManager.reset()
@pytest.fixture
def datagrid(manager):
"""Create a DataGrid instance."""
from myfasthtml.controls.DataGrid import DatagridConf
conf = DatagridConf(namespace="app", name="products", id="test-grid")
grid = DataGrid(manager, conf=conf, save_state=False, _id="test-datagrid")
# Add some columns
grid._state.columns = [
DataGridColumnState(col_id="amount", col_index=0, title="Amount", type=ColumnType.Number, visible=True),
DataGridColumnState(col_id="status", col_index=1, title="Status", type=ColumnType.Text, visible=True),
]
# Add some rows
grid._state.rows = [
DataGridRowState(0),
DataGridRowState(1),
]
yield grid
InstancesManager.reset()
@pytest.fixture
def editor(datagrid):
return DataGridFormattingEditor(datagrid, FormattingDSL())
# =============================================================================
# _get_format_rules() Hierarchy Tests
# =============================================================================
class TestFormatRulesHierarchy:
"""Tests for format rules hierarchy (cell > row > column > table > tables)."""
def test_i_can_get_cell_level_rules(self, datagrid):
"""Test that cell-level rules have highest priority."""
# Setup rules at different levels
cell_rules = [FormatRule(style=Style(preset="error"))]
column_rules = [FormatRule(style=Style(preset="success"))]
table_rules = [FormatRule(style=Style(preset="info"))]
datagrid._state.cell_formats["tcell_test-datagrid-0-0"] = cell_rules
datagrid._state.columns[0].format = column_rules
datagrid._state.table_format = table_rules
# Get rules for cell (0, 0)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
# Should return cell rules (highest priority)
assert rules == cell_rules
def test_i_can_get_row_level_rules(self, datagrid):
"""Test that row-level rules have second priority."""
# Setup rules at different levels
row_rules = [FormatRule(style=Style(preset="warning"))]
column_rules = [FormatRule(style=Style(preset="success"))]
table_rules = [FormatRule(style=Style(preset="info"))]
datagrid._state.rows[0].format = row_rules
datagrid._state.columns[0].format = column_rules
datagrid._state.table_format = table_rules
# Get rules for row 0 (no cell-level rules)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
# Should return row rules
assert rules == row_rules
def test_i_can_get_column_level_rules(self, datagrid):
"""Test that column-level rules have third priority."""
# Setup rules at different levels
column_rules = [FormatRule(style=Style(preset="success"))]
table_rules = [FormatRule(style=Style(preset="info"))]
datagrid._state.columns[0].format = column_rules
datagrid._state.table_format = table_rules
# Get rules for column (no cell or row rules)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
# Should return column rules
assert rules == column_rules
def test_i_can_get_table_level_rules(self, datagrid, manager):
"""Test that table-level rules have fourth priority."""
# Setup rules at different levels
table_rules = [FormatRule(style=Style(preset="info"))]
tables_rules = [FormatRule(style=Style(preset="neutral"))]
datagrid._state.table_format = table_rules
manager.all_tables_formats = tables_rules
# Get rules for cell (no higher level rules)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
# Should return table rules
assert rules == table_rules
def test_i_can_get_tables_level_rules(self, datagrid, manager):
"""Test that tables-level rules have lowest priority."""
# Setup global rules
tables_rules = [FormatRule(style=Style(preset="neutral"))]
manager.all_tables_formats = tables_rules
# Get rules for cell (no other rules)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
# Should return global tables rules
assert rules == tables_rules
def test_i_get_none_when_no_rules(self, datagrid):
"""Test that None is returned when no rules are defined."""
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
assert rules == []
@pytest.mark.parametrize("level,setup_func,expected_preset", [
("cell", lambda dg: dg._state.cell_formats.__setitem__("tcell_test-datagrid-0-0",
[FormatRule(style=Style(preset="error"))]), "error"),
("row", lambda dg: setattr(dg._state.rows[0], "format",
[FormatRule(style=Style(preset="warning"))]), "warning"),
("column", lambda dg: setattr(dg._state.columns[0], "format",
[FormatRule(style=Style(preset="success"))]), "success"),
("table", lambda dg: setattr(dg._state, "table_format",
[FormatRule(style=Style(preset="info"))]), "info"),
])
def test_hierarchy_priority(self, datagrid, level, setup_func, expected_preset):
"""Test that each level has correct priority in the hierarchy."""
setup_func(datagrid)
rules = datagrid._get_format_rules(0, 0, datagrid._state.columns[0])
assert rules is not None
assert len(rules) == 1
assert rules[0].style.preset == expected_preset
# =============================================================================
# DataGridFormattingEditor Integration Tests
# =============================================================================
class TestFormattingEditorIntegration:
"""Tests for DataGridFormattingEditor with table/tables scopes."""
def test_i_can_dispatch_table_rules(self, datagrid, editor):
"""Test that table rules are dispatched to DatagridState.table_format."""
dsl = '''
table "products":
style("info")
'''
editor.set_content(dsl)
editor.on_content_changed()
# Check that table_format is populated
assert len(datagrid._state.table_format) == 1
assert datagrid._state.table_format[0].style.preset == "info"
def test_i_cannot_use_wrong_table_name(self, datagrid, editor):
"""Test that wrong table name is rejected."""
dsl = '''
table "wrong_name":
style("error")
'''
editor.set_content(dsl)
editor.on_content_changed()
# Rules should not be applied (wrong table name)
assert len(datagrid._state.table_format) == 0
def test_i_can_dispatch_tables_rules(self, manager, datagrid, editor):
"""Test that tables rules are dispatched to DataGridsManager."""
dsl = '''
tables:
style("neutral")
format.number(precision=2)
'''
editor.set_content(dsl)
editor.on_content_changed()
# Check that manager.all_tables_formats is populated
assert len(manager.all_tables_formats) == 2
assert manager.all_tables_formats[0].style.preset == "neutral"
assert manager.all_tables_formats[1].formatter.precision == 2
def test_i_can_combine_all_scope_types(self, manager, datagrid, editor):
"""Test that all 5 scope types can be used together."""
dsl = '''
tables:
style(font_size="14px")
table "products":
format.number(precision=2)
column amount:
style("success") if value > 0
row 0:
style("neutral", bold=True)
cell (amount, 1):
style("error")
'''
editor.set_content(dsl)
editor.on_content_changed()
# Check all levels are populated
assert len(manager.all_tables_formats) == 1
assert len(datagrid._state.table_format) == 1
assert len(datagrid._state.columns[0].format) == 1
assert len(datagrid._state.rows[0].format) == 1
assert len(datagrid._state.cell_formats) == 1
def test_i_can_clear_table_format(self, datagrid, editor):
"""Test that table_format is cleared when DSL changes."""
# First set table rules
dsl = '''
table "products":
style("info")
'''
editor.set_content(dsl)
editor.on_content_changed()
assert len(datagrid._state.table_format) == 1
# Then remove them
editor.set_content("")
editor.on_content_changed()
assert len(datagrid._state.table_format) == 0
@pytest.mark.parametrize("table_name,should_apply", [
("products", True), # Correct name
("PRODUCTS", False), # Case sensitive
("product", False), # Partial match not allowed
("app.products", False), # Namespace not included
("other", False), # Completely wrong
])
def test_table_name_validation(self, datagrid, editor, table_name, should_apply):
"""Test that table name validation is case-sensitive and exact."""
dsl = f'''
table "{table_name}":
style("info")
'''
editor.set_content(dsl)
editor.on_content_changed()
if should_apply:
assert len(datagrid._state.table_format) == 1
else:
assert len(datagrid._state.table_format) == 0

View File

@@ -142,6 +142,49 @@ def test_i_can_detect_scope_with_multiple_declarations():
assert scope.column_name == "amount"
def test_i_can_detect_table_scope():
"""Test detection of table scope."""
text = 'table "products":\n style()'
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "table"
assert scope.table_name == "products"
def test_i_can_detect_table_scope_with_spaces():
"""Test detection of table scope with spaces in name."""
text = 'table "financial report":\n format()'
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "table"
assert scope.table_name == "financial report"
def test_i_can_detect_tables_scope():
"""Test detection of global tables scope."""
text = "tables:\n style()"
scope = detect_scope(text, current_line=1)
assert scope.scope_type == "tables"
assert scope.table_name is None
@pytest.mark.parametrize("scope_def,expected_type,expected_attrs", [
('column amount:\n style()', "column", {"column_name": "amount"}),
('row 5:\n style()', "row", {"row_index": 5}),
('cell (amount, 3):\n style()', "cell", {"column_name": "amount", "row_index": 3}),
('table "products":\n style()', "table", {"table_name": "products"}),
('tables:\n style()', "tables", {}),
])
def test_i_can_detect_all_scope_types(scope_def, expected_type, expected_attrs):
"""Test detection of all 5 scope types."""
scope = detect_scope(scope_def, current_line=1)
assert scope.scope_type == expected_type
for attr, value in expected_attrs.items():
assert getattr(scope, attr) == value
# =============================================================================
# Context Detection - Scope Contexts
# =============================================================================
@@ -227,6 +270,42 @@ def test_context_cell_row_after_comma_quoted():
assert context == Context.CELL_ROW
def test_context_table_name_after_table():
"""Test TABLE_NAME context after 'table '."""
text = "table "
cursor = Position(line=0, ch=6)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.TABLE_NAME
def test_context_tables_scope_after_tables():
"""Test TABLES_SCOPE context after 'tables'."""
text = "tables"
cursor = Position(line=0, ch=6)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == Context.TABLES_SCOPE
@pytest.mark.parametrize("text,cursor_ch,expected_context", [
("column ", 7, Context.COLUMN_NAME),
("row ", 4, Context.ROW_INDEX),
("cell ", 5, Context.CELL_START),
("table ", 6, Context.TABLE_NAME),
("tables", 6, Context.TABLES_SCOPE),
])
def test_i_can_detect_all_scope_contexts(text, cursor_ch, expected_context):
"""Test detection of all scope-related contexts."""
cursor = Position(line=0, ch=cursor_ch)
scope = DetectedScope()
context = detect_context(text, cursor, scope)
assert context == expected_context
# =============================================================================
# Context Detection - Rule Contexts
# =============================================================================
@@ -553,6 +632,32 @@ def test_suggestions_scope_keyword(provider):
assert "column" in labels
assert "row" in labels
assert "cell" in labels
assert "table" in labels
assert "tables" in labels
def test_suggestions_table_name(provider):
"""Test suggestions for TABLE_NAME context."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope()
suggestions = engine.get_suggestions(Context.TABLE_NAME, scope, "")
labels = [s.label for s in suggestions]
# Should suggest the current table name in quotes
assert '"app.orders"' in labels
def test_suggestions_tables_scope(provider):
"""Test suggestions for TABLES_SCOPE context."""
engine = FormattingCompletionEngine(provider, "app.orders")
scope = DetectedScope()
suggestions = engine.get_suggestions(Context.TABLES_SCOPE, scope, "")
labels = [s.label for s in suggestions]
# Should suggest colon to complete the scope
assert ":" in labels
def test_suggestions_style_preset(provider):

View File

@@ -17,6 +17,8 @@ from myfasthtml.core.formatting.dsl import (
ColumnScope,
RowScope,
CellScope,
TableScope,
TablesScope,
DSLSyntaxError,
)
@@ -125,6 +127,62 @@ cell tcell_grid1-3-2:
assert rules[0].scope.row is None
class TestTableScope:
"""Tests for table scope parsing."""
def test_i_can_parse_table_scope(self):
"""Test parsing a table scope."""
dsl = """
table "products":
style("neutral")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, TableScope)
assert rules[0].scope.table == "products"
def test_i_can_parse_table_scope_with_spaces(self):
"""Test parsing a table scope with spaces in name."""
dsl = """
table "financial report":
style("info")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, TableScope)
assert rules[0].scope.table == "financial report"
class TestTablesScope:
"""Tests for tables scope (global) parsing."""
def test_i_can_parse_tables_scope(self):
"""Test parsing the global tables scope."""
dsl = """
tables:
style("neutral")
"""
rules = parse_dsl(dsl)
assert len(rules) == 1
assert isinstance(rules[0].scope, TablesScope)
def test_i_can_parse_tables_scope_with_multiple_rules(self):
"""Test parsing tables scope with multiple rules."""
dsl = """
tables:
style("neutral")
format.number(precision=2)
"""
rules = parse_dsl(dsl)
assert len(rules) == 2
assert isinstance(rules[0].scope, TablesScope)
assert isinstance(rules[1].scope, TablesScope)
# =============================================================================
# Style Tests
# =============================================================================
@@ -497,6 +555,48 @@ row 0:
assert isinstance(rules[2].scope, RowScope)
assert rules[2].scope.row == 0
def test_i_can_parse_all_scope_types(self):
"""Test parsing all 5 scope types together."""
dsl = """
tables:
style(font_size="14px")
table "products":
format.number(precision=2)
column amount:
format("EUR")
row 0:
style("neutral", bold=True)
cell (amount, 5):
style("highlight")
"""
rules = parse_dsl(dsl)
assert len(rules) == 5
# First rule: tables (global)
assert isinstance(rules[0].scope, TablesScope)
# Second rule: table "products"
assert isinstance(rules[1].scope, TableScope)
assert rules[1].scope.table == "products"
# Third rule: column amount
assert isinstance(rules[2].scope, ColumnScope)
assert rules[2].scope.column == "amount"
# Fourth rule: row 0
assert isinstance(rules[3].scope, RowScope)
assert rules[3].scope.row == 0
# Fifth rule: cell (amount, 5)
assert isinstance(rules[4].scope, CellScope)
assert rules[4].scope.column == "amount"
assert rules[4].scope.row == 5
def test_i_can_parse_style_and_format_combined(self):
"""Test parsing style and format on same line."""
dsl = """