From 6160e91665bcc6577fff1b7a4c058906dfed2484 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sat, 7 Feb 2026 22:48:51 +0100 Subject: [PATCH] Added "table" and "tables" in the DSL --- docs/DataGrid Formatting DSL.md | 94 +++++- docs/DataGrid Formatting.md | 32 +- src/myfasthtml/controls/DataGrid.py | 14 +- .../controls/DataGridFormattingEditor.py | 35 ++- src/myfasthtml/controls/DataGridsManager.py | 3 +- src/myfasthtml/controls/DslEditor.py | 2 +- .../core/formatting/dsl/__init__.py | 4 +- .../completion/FormattingCompletionEngine.py | 16 +- .../formatting/dsl/completion/contexts.py | 29 +- .../core/formatting/dsl/completion/presets.py | 2 + src/myfasthtml/core/formatting/dsl/grammar.py | 4 + src/myfasthtml/core/formatting/dsl/parser.py | 12 +- src/myfasthtml/core/formatting/dsl/scopes.py | 16 +- .../core/formatting/dsl/transformer.py | 11 +- src/myfasthtml/core/instances.py | 11 +- tests/controls/test_datagrid_formatting.py | 279 ++++++++++++++++++ tests/core/formatting/dsl/test_completion.py | 105 +++++++ tests/core/formatting/test_dsl_parser.py | 102 ++++++- 18 files changed, 717 insertions(+), 54 deletions(-) create mode 100644 tests/controls/test_datagrid_formatting.py diff --git a/docs/DataGrid Formatting DSL.md b/docs/DataGrid Formatting DSL.md index 67c384e..629459d 100644 --- a/docs/DataGrid Formatting DSL.md +++ b/docs/DataGrid Formatting DSL.md @@ -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 :` | All cells in the column | -| **Row** | `row :` | All cells in the row | -| **Cell (coordinates)** | `cell (, ):` | Single cell by position | -| **Cell (ID)** | `cell :` | Single cell by ID | +| Scope | Syntax | Applies To | Specificity | +|-------|--------|------------|-------------| +| **Cell (coordinates)** | `cell (, ):` | Single cell by position | Highest (1) | +| **Cell (ID)** | `cell :` | Single cell by ID | Highest (1) | +| **Row** | `row :` | All cells in the row | High (2) | +| **Column** | `column :` | All cells in the column | Medium (3) | +| **Table** | `table "":` | 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` | diff --git a/docs/DataGrid Formatting.md b/docs/DataGrid Formatting.md index ee6ea85..6e934e4 100644 --- a/docs/DataGrid Formatting.md +++ b/docs/DataGrid Formatting.md @@ -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) | --- @@ -467,11 +475,13 @@ 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 | +| Level | Storage | Key | Status | +|------------|--------------------------------|---------|--------| +| **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 diff --git a/src/myfasthtml/controls/DataGrid.py b/src/myfasthtml/controls/DataGrid.py index 10d3580..17c3d00 100644 --- a/src/myfasthtml/controls/DataGrid.py +++ b/src/myfasthtml/controls/DataGrid.py @@ -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: diff --git a/src/myfasthtml/controls/DataGridFormattingEditor.py b/src/myfasthtml/controls/DataGridFormattingEditor.py index 5af094a..3891285 100644 --- a/src/myfasthtml/controls/DataGridFormattingEditor.py +++ b/src/myfasthtml/controls/DataGridFormattingEditor.py @@ -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,11 +63,13 @@ 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 rule = scoped_rule.rule - + if isinstance(scope, ColumnScope): columns_rules[scope.column].append(rule) elif isinstance(scope, RowScope): @@ -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(): @@ -103,10 +115,21 @@ 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") diff --git a/src/myfasthtml/controls/DataGridsManager.py b/src/myfasthtml/controls/DataGridsManager.py index 3320366..c0492ab 100644 --- a/src/myfasthtml/controls/DataGridsManager.py +++ b/src/myfasthtml/controls/DataGridsManager.py @@ -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) diff --git a/src/myfasthtml/controls/DslEditor.py b/src/myfasthtml/controls/DslEditor.py index eca8c70..1579e57 100644 --- a/src/myfasthtml/controls/DslEditor.py +++ b/src/myfasthtml/controls/DslEditor.py @@ -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}") diff --git a/src/myfasthtml/core/formatting/dsl/__init__.py b/src/myfasthtml/core/formatting/dsl/__init__.py index b085d79..86b4d38 100644 --- a/src/myfasthtml/core/formatting/dsl/__init__.py +++ b/src/myfasthtml/core/formatting/dsl/__init__.py @@ -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", diff --git a/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py b/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py index de44ce3..9295bea 100644 --- a/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py +++ b/src/myfasthtml/core/formatting/dsl/completion/FormattingCompletionEngine.py @@ -105,7 +105,13 @@ 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 # ================================================================= @@ -230,7 +236,13 @@ class FormattingCompletionEngine(BaseCompletionEngine): except Exception: 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 = [] diff --git a/src/myfasthtml/core/formatting/dsl/completion/contexts.py b/src/myfasthtml/core/formatting/dsl/completion/contexts.py index ce4df25..558e590 100644 --- a/src/myfasthtml/core/formatting/dsl/completion/contexts.py +++ b/src/myfasthtml/core/formatting/dsl/completion/contexts.py @@ -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: @@ -138,6 +140,17 @@ def detect_scope(text: str, current_line: int) -> DetectedScope: return 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 diff --git a/src/myfasthtml/core/formatting/dsl/completion/presets.py b/src/myfasthtml/core/formatting/dsl/completion/presets.py index db9243b..0889e00 100644 --- a/src/myfasthtml/core/formatting/dsl/completion/presets.py +++ b/src/myfasthtml/core/formatting/dsl/completion/presets.py @@ -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"), ] # ============================================================================= diff --git a/src/myfasthtml/core/formatting/dsl/grammar.py b/src/myfasthtml/core/formatting/dsl/grammar.py index 69350c0..20f3290 100644 --- a/src/myfasthtml/core/formatting/dsl/grammar.py +++ b/src/myfasthtml/core/formatting/dsl/grammar.py @@ -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 diff --git a/src/myfasthtml/core/formatting/dsl/parser.py b/src/myfasthtml/core/formatting/dsl/parser.py index a5faa4c..a4a2429 100644 --- a/src/myfasthtml/core/formatting/dsl/parser.py +++ b/src/myfasthtml/core/formatting/dsl/parser.py @@ -64,12 +64,18 @@ class DSLParser: lines = text.split("\n") lines = ["" if line.strip().startswith("#") else line for line in lines] text = "\n".join(lines) - + # 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: return self._parser.parse(text) except UnexpectedInput as e: diff --git a/src/myfasthtml/core/formatting/dsl/scopes.py b/src/myfasthtml/core/formatting/dsl/scopes.py index 9236d7e..2a5b95e 100644 --- a/src/myfasthtml/core/formatting/dsl/scopes.py +++ b/src/myfasthtml/core/formatting/dsl/scopes.py @@ -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 diff --git a/src/myfasthtml/core/formatting/dsl/transformer.py b/src/myfasthtml/core/formatting/dsl/transformer.py index 462c7d6..81fd765 100644 --- a/src/myfasthtml/core/formatting/dsl/transformer.py +++ b/src/myfasthtml/core/formatting/dsl/transformer.py @@ -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, @@ -67,7 +67,14 @@ class DSLTransformer(Transformer): def cell_id(self, items): 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]) diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py index 81024fa..2c66d0f 100644 --- a/src/myfasthtml/core/instances.py +++ b/src/myfasthtml/core/instances.py @@ -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" - assert len(res) > 0, f"No instance of type {cls.__name__} found" - return res[0] + 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): diff --git a/tests/controls/test_datagrid_formatting.py b/tests/controls/test_datagrid_formatting.py new file mode 100644 index 0000000..d2dd0e5 --- /dev/null +++ b/tests/controls/test_datagrid_formatting.py @@ -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 diff --git a/tests/core/formatting/dsl/test_completion.py b/tests/core/formatting/dsl/test_completion.py index 928507c..e761a7f 100644 --- a/tests/core/formatting/dsl/test_completion.py +++ b/tests/core/formatting/dsl/test_completion.py @@ -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): diff --git a/tests/core/formatting/test_dsl_parser.py b/tests/core/formatting/test_dsl_parser.py index 6cdb116..e75dcb6 100644 --- a/tests/core/formatting/test_dsl_parser.py +++ b/tests/core/formatting/test_dsl_parser.py @@ -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 # ============================================================================= @@ -496,7 +554,49 @@ row 0: # Third rule: 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 = """