Working in DataGridFormattingManager

This commit is contained in:
2026-03-13 22:13:01 +01:00
parent 3d1a391cba
commit 56fb3cf021
5 changed files with 419 additions and 1 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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',
),
}