From 56fb3cf0219d3e101a3f9eb7948d26e5abd67bbb Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Fri, 13 Mar 2026 22:13:01 +0100 Subject: [PATCH] Working in DataGridFormattingManager --- src/app.py | 13 +- .../controls/DataGridFormattingManager.py | 391 ++++++++++++++++++ src/myfasthtml/controls/IconsHelper.py | 4 + src/myfasthtml/core/formatting/dataclasses.py | 1 + src/myfasthtml/core/formatting/presets.py | 11 + 5 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 src/myfasthtml/controls/DataGridFormattingManager.py diff --git a/src/app.py b/src/app.py index c43144b..8f1c6e4 100644 --- a/src/app.py +++ b/src/app.py @@ -6,6 +6,7 @@ from fasthtml import serve from fasthtml.components import Div from myfasthtml.controls.CommandsDebugger import CommandsDebugger +from myfasthtml.controls.DataGridFormattingManager import DataGridFormattingManager from myfasthtml.controls.DataGridsManager import DataGridsManager from myfasthtml.controls.Dropdown import Dropdown from myfasthtml.controls.FileUpload import FileUpload @@ -18,7 +19,7 @@ from myfasthtml.core.dbengine_utils import DataFrameHandler from myfasthtml.core.instances import UniqueInstance from myfasthtml.icons.carbon import volume_object_storage from myfasthtml.icons.fluent_p2 import key_command16_regular -from myfasthtml.icons.fluent_p3 import folder_open20_regular +from myfasthtml.icons.fluent_p3 import folder_open20_regular, text_edit_style20_regular from myfasthtml.myfastapp import create_app with open('logging.yaml', 'r') as f: @@ -74,6 +75,15 @@ def index(session): layout.header_right.add(btn_show_right_drawer) layout.left_drawer.add(btn_show_instances_debugger, "Debugger") layout.left_drawer.add(btn_show_commands_debugger, "Debugger") + + # Parameters + formatting_manager = DataGridFormattingManager(layout) + btn_show_formatting_manager = mk.label("Formatting", + icon=text_edit_style20_regular, + command=add_tab("Formatting", formatting_manager), + id=formatting_manager.get_id()) + layout.left_drawer.add(btn_show_formatting_manager, "Parameters") + layout.left_drawer.add(btn_file_upload, "Test") layout.left_drawer.add(btn_popup, "Test") @@ -83,6 +93,7 @@ def index(session): dgs_manager.mk_main_icons(), cls="mf-layout-group flex gap-3")) layout.left_drawer.add(dgs_manager, "Documents") + layout.set_main(tabs_manager) # keyboard shortcuts diff --git a/src/myfasthtml/controls/DataGridFormattingManager.py b/src/myfasthtml/controls/DataGridFormattingManager.py new file mode 100644 index 0000000..b22d170 --- /dev/null +++ b/src/myfasthtml/controls/DataGridFormattingManager.py @@ -0,0 +1,391 @@ +import logging +from typing import Optional + +from fasthtml.common import Form, Fieldset, Label, Input, Span +from fasthtml.components import Div + +from myfasthtml.controls.BaseCommands import BaseCommands +from myfasthtml.controls.DslEditor import DslEditor, DslEditorConf +from myfasthtml.controls.IconsHelper import IconsHelper +from myfasthtml.controls.Menu import Menu, MenuConf +from myfasthtml.controls.Panel import Panel, PanelConf +from myfasthtml.controls.Search import Search +from myfasthtml.controls.helpers import mk +from myfasthtml.core.commands import Command +from myfasthtml.core.dbmanager import DbObject +from myfasthtml.core.formatting.dataclasses import RulePreset +from myfasthtml.core.formatting.dsl import parse_dsl +from myfasthtml.core.formatting.dsl.definition import FormattingDSL +from myfasthtml.core.formatting.presets import DEFAULT_RULE_PRESETS +from myfasthtml.core.instances import SingleInstance + +logger = logging.getLogger("DataGridFormattingManager") + +_DSL_PLACEHOLDER = 'tables:\n style("error") if value < 0\n style("success") if value > 0' + + +class DataGridFormattingManagerState(DbObject): + def __init__(self, owner): + with self.initializing(): + super().__init__(owner) + self.presets: list = [] + self.selected_name: Optional[str] = None + self.ns_mode: str = "view" + + +class Commands(BaseCommands): + def new_preset(self): + return Command( + "NewPreset", + "New preset", + self._owner, + self._owner.on_new_preset, + icon=IconsHelper.get("Add20Regular"), + ).htmx(target=f"#{self._id}", swap="outerHTML") + + def save_preset(self): + return Command( + "SavePreset", + "Save preset", + self._owner, + self._owner.on_save_preset, + icon=IconsHelper.get("Save20Regular"), + ).htmx(target=f"#{self._id}", swap="outerHTML") + + def rename_preset(self): + return Command( + "RenamePreset", + "Rename preset", + self._owner, + self._owner.on_rename_preset, + icon=IconsHelper.get("Rename20Regular"), + ).htmx(target=f"#{self._id}", swap="outerHTML") + + def delete_preset(self): + return Command( + "DeletePreset", + "Delete preset", + self._owner, + self._owner.on_delete_preset, + icon=IconsHelper.get("Delete20Regular"), + ).htmx(target=f"#{self._id}", swap="outerHTML") + + def confirm_new(self): + return Command( + "ConfirmNew", + "Confirm new preset", + self._owner, + self._owner.on_confirm_new, + ).htmx(target=f"#{self._id}", swap="outerHTML") + + def confirm_rename(self): + return Command( + "ConfirmRename", + "Confirm rename", + self._owner, + self._owner.on_confirm_rename, + ).htmx(target=f"#{self._id}", swap="outerHTML") + + def cancel(self): + return Command( + "Cancel", + "Cancel", + self._owner, + self._owner.on_cancel, + ).htmx(target=f"#{self._id}", swap="outerHTML") + + +class DataGridFormattingManager(SingleInstance): + def __init__(self, parent, _id=None): + super().__init__(parent, _id=_id) + self._state = DataGridFormattingManagerState(self) + self.commands = Commands(self) + + self._panel = Panel( + self, + conf=PanelConf(left=True, right=False, left_title="Presets"), + _id="-panel", + ) + self._menu = Menu( + self, + conf=MenuConf(fixed_items=["NewPreset", "SavePreset", "RenamePreset", "DeletePreset"]), + save_state=False, + _id="-menu", + ) + self._search = Search( + self, + items_names="Presets", + template=self._mk_preset_item, + _id="-search", + ) + self._editor = DslEditor( + self, + dsl=FormattingDSL(), + conf=DslEditorConf( + save_button=False, + autocompletion=False, + linting=False, + placeholder=_DSL_PLACEHOLDER, + ), + save_state=False, + _id="-editor", + ) + + logger.debug(f"DataGridFormattingManager created with id={self._id}") + + # === Helpers === + + def _get_all_presets(self) -> list: + """Returns builtin presets followed by user presets.""" + return list(DEFAULT_RULE_PRESETS.values()) + self._state.presets + + def _is_builtin(self, name: str) -> bool: + return name in DEFAULT_RULE_PRESETS + + def _get_selected_preset(self) -> Optional[RulePreset]: + if not self._state.selected_name: + return None + for p in self._get_all_presets(): + if p.name == self._state.selected_name: + return p + return None + + def _get_user_preset(self, name: str) -> Optional[RulePreset]: + for p in self._state.presets: + if p.name == name: + return p + return None + + def _parse_dsl_to_rules(self, dsl_text: str) -> list: + """Parse DSL text and extract FormatRule objects, ignoring scopes.""" + try: + scoped_rules = parse_dsl(dsl_text) + return [sr.rule for sr in scoped_rules] + except Exception: + return [] + + # === Command handlers === + + def on_new_preset(self): + self._state.ns_mode = "new" + return self.render() + + def on_save_preset(self): + if not self._state.selected_name or self._is_builtin(self._state.selected_name): + return self.render() + preset = self._get_user_preset(self._state.selected_name) + if preset is None: + return self.render() + dsl = self._editor.get_content() + preset.dsl = dsl + preset.rules = self._parse_dsl_to_rules(dsl) + logger.debug(f"Saved preset '{preset.name}' with {len(preset.rules)} rules") + return self.render() + + def on_rename_preset(self): + if not self._state.selected_name or self._is_builtin(self._state.selected_name): + return self.render() + self._state.ns_mode = "rename" + return self.render() + + def on_delete_preset(self): + if not self._state.selected_name or self._is_builtin(self._state.selected_name): + return self.render() + self._state.presets = [p for p in self._state.presets if p.name != self._state.selected_name] + self._state.selected_name = None + self._editor.set_content("") + self._editor.conf.readonly = False + logger.debug(f"Deleted preset '{self._state.selected_name}'") + return self.render() + + def on_select_preset(self, name: str): + preset = None + for p in self._get_all_presets(): + if p.name == name: + preset = p + break + if preset is None: + return self.render() + self._state.selected_name = name + self._state.ns_mode = "view" + self._editor.set_content(preset.dsl) + self._editor.conf.readonly = self._is_builtin(name) + logger.debug(f"Selected preset '{name}', readonly={self._editor.conf.readonly}") + return self.render() + + def on_confirm_new(self, client_response): + name = (client_response.get("name") or "").strip() + description = (client_response.get("description") or "").strip() + + if not name: + self._state.ns_mode = "view" + return self.render() + + all_names = {p.name for p in self._get_all_presets()} + if name in all_names: + logger.debug(f"Cannot create preset '{name}': name already exists") + self._state.ns_mode = "view" + return self.render() + + new_preset = RulePreset(name=name, description=description, rules=[], dsl="") + self._state.presets.append(new_preset) + self._state.selected_name = name + self._state.ns_mode = "view" + self._editor.set_content("") + self._editor.conf.readonly = False + logger.debug(f"Created preset '{name}'") + return self.render() + + def on_confirm_rename(self, client_response): + name = (client_response.get("name") or "").strip() + description = (client_response.get("description") or "").strip() + + preset = self._get_user_preset(self._state.selected_name) + if not name or preset is None: + self._state.ns_mode = "view" + return self.render() + + if name != preset.name: + all_names = {p.name for p in self._get_all_presets()} + if name in all_names: + logger.debug(f"Cannot rename to '{name}': name already exists") + self._state.ns_mode = "view" + return self.render() + + old_name = preset.name + preset.name = name + preset.description = description + self._state.selected_name = name + self._state.ns_mode = "view" + logger.debug(f"Renamed preset '{old_name}' → '{name}'") + return self.render() + + def on_cancel(self): + self._state.ns_mode = "view" + return self.render() + + # === Rendering === + + def _mk_preset_item(self, preset: RulePreset): + is_active = self._state.selected_name == preset.name + is_builtin = self._is_builtin(preset.name) + + badges = [] + if preset.has_formatter(): + badges.append(Span("format()", cls="badge badge-xs badge-secondary")) + if preset.has_style(): + badges.append(Span("style()", cls="badge badge-xs badge-primary")) + if is_builtin: + badges.append(Span("built-in", cls="badge badge-xs badge-ghost")) + + item_cls = "mf-fmgr-preset-item" + if is_active: + item_cls += " mf-fmgr-preset-item-active" + + select_cmd = Command( + "SelectPreset", + f"Select {preset.name}", + self, + self.on_select_preset, + args=[preset.name], + ).htmx(target=f"#{self._id}", swap="outerHTML") + + return mk.mk( + Div( + Div(preset.name, cls="mf-fmgr-preset-name"), + Div(preset.description, cls="mf-fmgr-preset-desc") if preset.description else None, + Div(*badges, cls="mf-fmgr-preset-badges") if badges else None, + cls=item_cls, + ), + command=select_cmd, + ) + + def _mk_preset_header(self, preset: RulePreset, is_builtin: bool): + badges = [] + if preset.has_formatter(): + badges.append(Span("format()", cls="badge badge-secondary")) + if preset.has_style(): + badges.append(Span("style()", cls="badge badge-primary")) + if is_builtin: + badges.append(Span("built-in", cls="badge badge-ghost")) + + return Div( + Div( + Div(preset.name, cls="text-sm font-semibold"), + Div(preset.description, cls="text-xs opacity-50") if preset.description else None, + ), + Div(*badges, cls="flex gap-1") if badges else None, + cls="mf-fmgr-editor-meta", + ) + + def _mk_editor_view(self): + preset = self._get_selected_preset() + if preset is None: + return Div("Select a preset to edit", cls="mf-fmgr-placeholder p-4 text-sm opacity-50") + is_builtin = self._is_builtin(preset.name) + return Div( + self._mk_preset_header(preset, is_builtin), + self._editor, + cls="mf-fmgr-editor-view", + ) + + def _mk_new_form(self): + return Div( + Form( + Fieldset( + Label("Name"), + Input(name="name", cls="input input-sm w-full"), + Label("Description"), + Input(name="description", cls="input input-sm w-full"), + legend="New preset", + cls="fieldset border-base-300 rounded-box p-4", + ), + mk.dialog_buttons( + on_ok=self.commands.confirm_new(), + on_cancel=self.commands.cancel(), + ), + ), + cls="mf-fmgr-form p-4", + ) + + def _mk_rename_form(self): + preset = self._get_selected_preset() + return Div( + Form( + Fieldset( + Label("Name"), + Input(name="name", value=preset.name if preset else "", cls="input input-sm w-full"), + Label("Description"), + Input(name="description", value=preset.description if preset else "", cls="input input-sm w-full"), + legend="Rename preset", + cls="fieldset border-base-300 rounded-box p-4", + ), + mk.dialog_buttons( + on_ok=self.commands.confirm_rename(), + on_cancel=self.commands.cancel(), + ), + ), + cls="mf-fmgr-form p-4", + ) + + def _mk_main_content(self): + if self._state.ns_mode == "new": + return self._mk_new_form() + elif self._state.ns_mode == "rename": + return self._mk_rename_form() + return self._mk_editor_view() + + def render(self): + self._search.set_items(self._get_all_presets()) + self._panel._main = self._mk_main_content() + self._panel._left = self._search + + return Div( + self._menu, + self._panel, + id=self._id, + cls="mf-formatting-manager", + ) + + def __ft__(self): + return self.render() diff --git a/src/myfasthtml/controls/IconsHelper.py b/src/myfasthtml/controls/IconsHelper.py index 10be1c7..df48a23 100644 --- a/src/myfasthtml/controls/IconsHelper.py +++ b/src/myfasthtml/controls/IconsHelper.py @@ -1,6 +1,7 @@ from fastcore.basics import NotStr from myfasthtml.core.constants import ColumnType +from myfasthtml.core.utils import pascal_to_snake from myfasthtml.icons.fluent import question20_regular, brain_circuit20_regular, number_symbol20_regular, \ number_row20_regular from myfasthtml.icons.fluent_p1 import checkbox_checked20_regular, checkbox_unchecked20_regular, \ @@ -63,6 +64,9 @@ class IconsHelper: _UTILITY_MODULES = {'manage_icons', 'update_icons'} + if name[0].isupper(): + name = pascal_to_snake(name) + if package: module = importlib.import_module(f"myfasthtml.icons.{package}") icon = getattr(module, name, None) diff --git a/src/myfasthtml/core/formatting/dataclasses.py b/src/myfasthtml/core/formatting/dataclasses.py index afd298f..50a8e8a 100644 --- a/src/myfasthtml/core/formatting/dataclasses.py +++ b/src/myfasthtml/core/formatting/dataclasses.py @@ -190,6 +190,7 @@ class RulePreset: name: str description: str rules: list[FormatRule] = field(default_factory=list) + dsl: str = "" def has_formatter(self) -> bool: """Returns True if at least one rule defines a formatter.""" diff --git a/src/myfasthtml/core/formatting/presets.py b/src/myfasthtml/core/formatting/presets.py index 2c764e2..eac0380 100644 --- a/src/myfasthtml/core/formatting/presets.py +++ b/src/myfasthtml/core/formatting/presets.py @@ -45,6 +45,9 @@ DEFAULT_RULE_PRESETS: dict[str, RulePreset] = { formatter=NumberFormatter(precision=0, thousands_sep=" "), ), ], + dsl='tables:\n' + ' format.number(precision=0, prefix="(", suffix=")", absolute=True, thousands_sep=" ") if value < 0\n' + ' format.number(precision=0, thousands_sep=" ") if value > 0\n', ), "traffic_light": RulePreset( name="traffic_light", @@ -54,6 +57,10 @@ DEFAULT_RULE_PRESETS: dict[str, RulePreset] = { FormatRule(condition=Condition(operator="==", value=0), style=Style(preset="warning")), FormatRule(condition=Condition(operator=">", value=0), style=Style(preset="success")), ], + dsl='tables:\n' + ' style("error") if value < 0\n' + ' style("warning") if value == 0\n' + ' style("success") if value > 0\n', ), "budget_variance": RulePreset( name="budget_variance", @@ -73,6 +80,10 @@ DEFAULT_RULE_PRESETS: dict[str, RulePreset] = { formatter=NumberFormatter(precision=1, suffix="%", multiplier=100), ), ], + dsl='tables:\n' + ' format.number(precision=1, suffix="%", multiplier=100) style("error") if value < 0\n' + ' format.number(precision=1, suffix="%", multiplier=100) style("warning") if value > 0.1\n' + ' format.number(precision=1, suffix="%", multiplier=100)\n', ), }