Reimplementing Columns Management

This commit is contained in:
2026-03-08 12:03:07 +01:00
parent 30a77d1171
commit e01d2cd74b
16 changed files with 336 additions and 76 deletions

View File

@@ -1,9 +1,8 @@
# Commands used # Commands used
``` ```
cd src/myfasthtml/assets
# Url to get codemirror resources : https://cdnjs.com/libraries/codemirror # Url to get codemirror resources : https://cdnjs.com/libraries/codemirror
cd src/myfasthtml/assets/codemirror/
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/codemirror.min.js wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/codemirror.min.js
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/codemirror.min.css wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/codemirror.min.css
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/hint/show-hint.min.js wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/hint/show-hint.min.js
@@ -12,4 +11,8 @@ wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/display/pla
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/lint/lint.min.css wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/lint/lint.min.css
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/lint/lint.min.js wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/lint/lint.min.js
wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/mode/simple.min.js wget https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.20/addon/mode/simple.min.js
# Url for SortableJS : https://cdnjs.com/libraries/Sortable
cd src/myfasthtml/assets/sortablejs/
wget https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.6/Sortable.min.js
``` ```

View File

@@ -1,5 +1,11 @@
.mf-search {
display: grid;
grid-template-rows: auto 1fr;
height: 100%;
}
.mf-search-results { .mf-search-results {
margin-top: 0.5rem; margin-top: 0.5rem;
/*max-height: 400px;*/ overflow-y: auto;
overflow: auto; min-height: 0;
} }

File diff suppressed because one or more lines are too long

View File

@@ -10,7 +10,7 @@ from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.CycleStateControl import CycleStateControl from myfasthtml.controls.CycleStateControl import CycleStateControl
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager from myfasthtml.controls.DataGridColumnsList import DataGridColumnsList
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
from myfasthtml.controls.DslEditor import DslEditorConf from myfasthtml.controls.DslEditor import DslEditorConf
from myfasthtml.controls.IconsHelper import IconsHelper from myfasthtml.controls.IconsHelper import IconsHelper
@@ -259,9 +259,9 @@ class DataGrid(MultipleInstance):
self.change_selection_mode() self.change_selection_mode()
# add columns manager # add columns manager
self._columns_manager = DataGridColumnsManager(self) self._columns_list = DataGridColumnsList(self)
self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed()) # self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed())
self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed()) # self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed())
if self._settings.enable_formatting: if self._settings.enable_formatting:
provider = InstancesManager.get_by_type(self._session, DatagridMetadataProvider, None) provider = InstancesManager.get_by_type(self._session, DatagridMetadataProvider, None)
@@ -494,6 +494,22 @@ class DataGrid(MultipleInstance):
self._state.save() self._state.save()
def handle_reorder_columns(self, order: list[str]):
"""Reorder columns based on a full ordered list of column IDs.
Args:
order: List of col_id strings in the desired display order.
Columns not present in order are appended at the end.
"""
logger.debug(f"handle_reorder_columns: {order=}")
columns_by_id = {c.col_id: c for c in self._state.columns}
new_order = [columns_by_id[col_id] for col_id in order if col_id in columns_by_id]
remaining = [c for c in self._state.columns if c.col_id not in order]
self._state.columns = new_order + remaining
self._init_columns()
self._state.save()
return self.render_partial("table")
def calculate_optimal_column_width(self, col_id: str) -> int: def calculate_optimal_column_width(self, col_id: str) -> int:
""" """
Calculate optimal width for a column based on content. Calculate optimal width for a column based on content.
@@ -612,19 +628,16 @@ class DataGrid(MultipleInstance):
def handle_toggle_columns_manager(self): def handle_toggle_columns_manager(self):
logger.debug(f"toggle_columns_manager") logger.debug(f"toggle_columns_manager")
self._panel.set_title(side="right", title="Columns") self._panel.set_title(side="right", title="Columns")
self._columns_manager.adding_new_column = False self._panel.set_right(self._columns_list)
self._columns_manager.set_all_columns(True)
self._columns_manager.unbind_command(("ShowAllColumns", "SaveColumnDetails"))
self._panel.set_right(self._columns_manager)
def handle_toggle_new_column_editor(self): def handle_toggle_new_column_editor(self):
logger.debug(f"handle_toggle_new_column_editor") logger.debug(f"handle_toggle_new_column_editor")
self._panel.set_title(side="right", title="Columns") self._panel.set_title(side="right", title="Columns")
self._columns_manager.adding_new_column = True self._columns_list.adding_new_column = True
self._columns_manager.set_all_columns(False) self._columns_list.set_all_columns(False)
self._columns_manager.bind_command(("ShowAllColumns", "SaveColumnDetails"), self._columns_list.bind_command(("ShowAllColumns", "SaveColumnDetails"),
self._panel.commands.set_side_visible("right", False)) self._panel.commands.set_side_visible("right", False))
self._panel.set_right(self._columns_manager) self._panel.set_right(self._columns_list)
def handle_toggle_formatting_editor(self): def handle_toggle_formatting_editor(self):
logger.debug(f"toggle_formatting_editor") logger.debug(f"toggle_formatting_editor")
@@ -670,6 +683,9 @@ class DataGrid(MultipleInstance):
manager = InstancesManager.get_by_type(self._session, DataServicesManager, None) manager = InstancesManager.get_by_type(self._session, DataServicesManager, None)
return manager.get_formula_engine() if manager is not None else None return manager.get_formula_engine() if manager is not None else None
def get_columns(self):
return self._columns
@staticmethod @staticmethod
def get_grid_id_from_data_service_id(data_service_id): def get_grid_id_from_data_service_id(data_service_id):
return data_service_id.replace(DataService.compute_prefix(), DataGrid.compute_prefix(), 1) return data_service_id.replace(DataService.compute_prefix(), DataGrid.compute_prefix(), 1)

View File

@@ -0,0 +1,94 @@
import logging
from fasthtml.components import *
from myfasthtml.controls.BaseCommands import BaseCommands
from myfasthtml.controls.IconsHelper import IconsHelper
from myfasthtml.controls.Search import Search
from myfasthtml.controls.Sortable import Sortable
from myfasthtml.controls.datagrid_objects import DataGridColumnState
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance
from myfasthtml.icons.fluent_p1 import chevron_right20_regular
from myfasthtml.icons.tabler import grip_horizontal
logger = logging.getLogger("DataGridColumnsList")
class Commands(BaseCommands):
def on_reorder(self):
return Command("ReorderColumns",
"Reorder columns in DataGrid",
self._owner,
self._owner.handle_on_reorder
).htmx(target=f"#{self._id}")
class DataGridColumnsList(MultipleInstance):
"""
Show the list of columns in a DataGrid.
You can set the visibility of each column.
You can also reorder the columns via drag and drop.
"""
def __init__(self, parent, _id=None):
super().__init__(parent, _id=_id)
self.commands = Commands(self)
@property
def columns(self):
from myfasthtml.core.constants import ColumnType
return [c for c in self._parent.get_columns() if c.type != ColumnType.RowSelection_]
def handle_on_reorder(self, order: list):
logger.debug(f"on_reorder {order=}")
ret = self._parent.handle_reorder_columns(order)
return self.render(), ret
def mk_column_label(self, col_def: DataGridColumnState):
return Div(
mk.icon(grip_horizontal, cls="mf-drag-handle cursor-grab mr-1 opacity-40"),
mk.mk(
Input(type="checkbox", cls="checkbox checkbox-sm", checked=col_def.visible),
# command=self.commands.toggle_column(col_def.col_id)
),
mk.mk(
Div(
Div(mk.label(col_def.col_id, icon=IconsHelper.get(col_def.type), cls="ml-2")),
Div(mk.icon(chevron_right20_regular), cls="mr-2"),
cls="dt2-column-manager-label"
),
# command=self.commands.show_column_details(col_def.col_id)
),
cls="flex mb-1 items-center",
id=f"tcolman_{self._id}-{col_def.col_id}",
data_sort_id=col_def.col_id,
)
def mk_columns(self):
return Search(self,
items_names="Columns",
items=self.columns,
get_attr=lambda x: x.col_id,
get_id=lambda x: x.col_id,
template=self.mk_column_label,
max_height=None,
_id="-Search"
)
def render(self):
search = self.mk_columns()
sortable = Sortable(self,
command=self.commands.on_reorder(),
container_id=f"{search.get_id()}-results",
handle=".mf-drag-handle",
_id="-sortable")
return Div(search,
sortable,
id=self._id,
cls="pt-2",
style="height: 100%;")
def __ft__(self):
return self.render()

View File

@@ -54,6 +54,9 @@ class IconsHelper:
if name in IconsHelper._icons: if name in IconsHelper._icons:
return IconsHelper._icons[name] return IconsHelper._icons[name]
if not isinstance(name, str):
return question20_regular
import importlib import importlib
import pkgutil import pkgutil
import myfasthtml.icons as icons_pkg import myfasthtml.icons as icons_pkg
@@ -76,7 +79,7 @@ class IconsHelper:
IconsHelper._icons[name] = icon IconsHelper._icons[name] = icon
return icon return icon
return None return question20_regular
@staticmethod @staticmethod
def reset(): def reset():

View File

@@ -14,12 +14,13 @@ logger = logging.getLogger("Search")
class Commands(BaseCommands): class Commands(BaseCommands):
def search(self): def search(self):
return (Command("Search", return Command("Search",
f"Search {self._owner.items_names}", f"Search {self._owner.items_names}",
self._owner, self._owner,
self._owner.on_search).htmx(target=f"#{self._owner.get_id()}-results", self._owner.on_search).htmx(target=f"#{self._owner.get_id()}-results",
trigger="keyup changed delay:300ms", trigger="keyup changed delay:300ms",
swap="innerHTML")) swap="innerHTML",
auto_swap_oob=False)
class Search(MultipleInstance): class Search(MultipleInstance):
@@ -45,6 +46,7 @@ class Search(MultipleInstance):
items_names=None, # what is the name of the items to filter items_names=None, # what is the name of the items to filter
items=None, # first set of items to filter items=None, # first set of items to filter
get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter get_attr: Callable[[Any], str] = None, # items is a list of objects: how to get the str to filter
get_id: Callable[[Any], str] = None, # use for deduplication
template: Callable[[Any], Any] = None, # once filtered, what to render ? template: Callable[[Any], Any] = None, # once filtered, what to render ?
max_height: int = 400): max_height: int = 400):
""" """
@@ -65,6 +67,7 @@ class Search(MultipleInstance):
self.items = items or [] self.items = items or []
self.filtered = self.items.copy() self.filtered = self.items.copy()
self.get_attr = get_attr or (lambda x: x) self.get_attr = get_attr or (lambda x: x)
self.get_item_id = get_id
self.template = template or (lambda x: Div(self.get_attr(x))) self.template = template or (lambda x: Div(self.get_attr(x)))
self.commands = Commands(self) self.commands = Commands(self)
self.max_height = max_height self.max_height = max_height
@@ -86,6 +89,7 @@ class Search(MultipleInstance):
return tuple(self._mk_search_results()) return tuple(self._mk_search_results())
def search(self, query): def search(self, query):
logger.debug(f"search {query=}") logger.debug(f"search {query=}")
if query is None or query.strip() == "": if query is None or query.strip() == "":
self.filtered = self.items.copy() self.filtered = self.items.copy()
@@ -93,24 +97,39 @@ class Search(MultipleInstance):
else: else:
res_seq = subsequence_matching(query, self.items, get_attr=self.get_attr) res_seq = subsequence_matching(query, self.items, get_attr=self.get_attr)
res_fuzzy = fuzzy_matching(query, self.items, get_attr=self.get_attr) res_fuzzy = fuzzy_matching(query, self.items, get_attr=self.get_attr)
self.filtered = res_seq + res_fuzzy self.filtered = self._unique_items(res_seq + res_fuzzy)
return self.filtered return self.filtered
def _mk_search_results(self): def _mk_search_results(self):
return [self.template(item) for item in self.filtered] return [self.template(item) for item in self.filtered]
def _unique_items(self, items: list):
if self.get_item_id is None:
return items
already_seen = set()
res = []
for item in items:
_id = self.get_item_id(item)
if _id not in already_seen:
already_seen.add(_id)
res.append(item)
return res
def render(self): def render(self):
return Div( return Div(
mk.mk(Input(name="query", id=f"{self._id}-search", type="text", placeholder="Search...", cls="input input-xs"), mk.mk(Input(name="query",
id=f"{self._id}-search",
type="text", placeholder="Search...",
cls="input input-xs w-full"),
command=self.commands.search()), command=self.commands.search()),
Div( Div(*self._mk_search_results(),
*self._mk_search_results(), id=f"{self._id}-results",
id=f"{self._id}-results", cls="mf-search-results"),
cls="mf-search-results",
style="max-height: 400px;" if self.max_height else None
),
id=f"{self._id}", id=f"{self._id}",
cls="mf-search",
style=f"max-height: {self.max_height}px;" if self.max_height else None
) )
def __ft__(self): def __ft__(self):

View File

@@ -0,0 +1,91 @@
"""
Sortable control for drag-and-drop reordering of list items.
Wraps SortableJS to enable drag-and-drop on any container, posting
the new item order to the server via HTMX after each drag operation.
Requires SortableJS to be loaded via create_app(sortable=True).
"""
import logging
from typing import Optional
from fasthtml.components import Script
from myfasthtml.core.commands import Command
from myfasthtml.core.instances import MultipleInstance
logger = logging.getLogger("Sortable")
class Sortable(MultipleInstance):
"""
Composable control that enables SortableJS drag-and-drop on a container.
Place this inside a render() method alongside the sortable container.
Items in the container must have a ``data-sort-id`` attribute identifying
each item. After a drag, the new order is POSTed to the server via the
provided command.
Args:
parent: Parent instance that owns this control.
command: Command to execute after reordering. Its handler must accept
an ``order: list`` parameter receiving the sorted IDs.
_id: Optional custom ID suffix.
container_id: ID of the DOM element to make sortable. Defaults to
``parent.get_id()`` if not provided.
handle: Optional CSS selector for the drag handle within each item.
If None, the entire item is draggable.
group: Optional SortableJS group name to allow dragging between
multiple connected lists.
"""
def __init__(self,
parent,
command: Command,
_id: Optional[str] = None,
container_id: Optional[str] = None,
handle: Optional[str] = None,
group: Optional[str] = None):
super().__init__(parent, _id=_id)
self._command = command
self._container_id = container_id
self._handle = handle
self._group = group
def render(self):
container_id = self._container_id or self._parent.get_id()
opts = self._command.ajax_htmx_options()
js_opts = ["animation: 150"]
if self._handle:
js_opts.append(f"handle: '{self._handle}'")
if self._group:
js_opts.append(f"group: '{self._group}'")
existing_values = ", ".join(f'"{k}": "{v}"' for k, v in opts["values"].items())
js_opts.append(f"""onEnd: function(evt) {{
var items = Array.from(document.getElementById('{container_id}').children)
.map(function(el) {{ return el.dataset.sortId; }})
.filter(Boolean);
htmx.ajax('POST', '{opts["url"]}', {{
target: '{opts["target"]}',
swap: '{opts["swap"]}',
values: {{ {existing_values}, order: items.join(',') }}
}});
}}""")
js_opts_str = ",\n ".join(js_opts)
script = f"""(function() {{
var container = document.getElementById('{container_id}');
if (!container) {{ return; }}
new Sortable(container, {{
{js_opts_str}
}});
}})();"""
logger.debug(f"Sortable rendered for container={container_id}")
return Script(script)
def __ft__(self):
return self.render()

View File

@@ -5,7 +5,7 @@ from myfasthtml.core.data.DataService import DataService
from myfasthtml.core.formula.engine import FormulaEngine from myfasthtml.core.formula.engine import FormulaEngine
from myfasthtml.core.instances import SingleInstance from myfasthtml.core.instances import SingleInstance
logger = logging.getLogger(__name__) logger = logging.getLogger("DataServicesManager")
class DataServicesManager(SingleInstance): class DataServicesManager(SingleInstance):
@@ -75,12 +75,13 @@ class DataServicesManager(SingleInstance):
Returns: Returns:
The restored DataService instance. The restored DataService instance.
""" """
logger.debug(f"restore_service {grid_id=}")
if grid_id in self._services: if grid_id in self._services:
return self._services[grid_id] return self._services[grid_id]
service = DataService(self, _id=grid_id) service = DataService(self, _id=grid_id)
self._services[grid_id] = service self._services[grid_id] = service
logger.debug("DataService restored for grid_id=%s", grid_id)
return service return service
def remove_service(self, grid_id: str) -> None: def remove_service(self, grid_id: str) -> None:

View File

@@ -47,8 +47,8 @@ class DbObject:
def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True): def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True):
self._owner = owner self._owner = owner
self._name = name or owner.get_id() self._name = name or owner.get_id()
if self._name.startswith(("#", "-")) and owner.get_parent() is not None: if self._name.startswith(("#", "-")) and owner is not None:
self._name = owner.get_parent().get_id() + self._name self._name = owner.get_id() + self._name
self._db_manager = db_manager or DbManager(self._owner) self._db_manager = db_manager or DbManager(self._owner)
self._save_state = save_state self._save_state = save_state

View File

@@ -55,7 +55,7 @@ class BaseInstance:
if key in InstancesManager.instances: if key in InstancesManager.instances:
res = InstancesManager.instances[key] res = InstancesManager.instances[key]
if type(res) is not cls: if type(res) is not cls:
raise TypeError(f"Instance with id {_id} already exists, but is of type {type(res)}") raise TypeError(f"Instance with id {_id} already exists, but is of type {type(res)} (instead of {cls})")
if VERBOSE_VERBOSE: if VERBOSE_VERBOSE:
logger.debug(f" instance {_id} already exists, returning existing instance") logger.debug(f" instance {_id} already exists, returning existing instance")

View File

@@ -78,6 +78,7 @@ def include_assets(module_name: str, order: Optional[List[str]] = None) -> list:
def create_app(daisyui: Optional[bool] = True, def create_app(daisyui: Optional[bool] = True,
vis: Optional[bool] = True, vis: Optional[bool] = True,
code_mirror: Optional[bool] = True, code_mirror: Optional[bool] = True,
sortable: Optional[bool] = True,
protect_routes: Optional[bool] = True, protect_routes: Optional[bool] = True,
mount_auth_app: Optional[bool] = False, mount_auth_app: Optional[bool] = False,
base_url: Optional[str] = None, base_url: Optional[str] = None,
@@ -95,6 +96,9 @@ def create_app(daisyui: Optional[bool] = True,
:param code_mirror: Flag to enable or disable inclusion of Code Mirror (https://codemirror.net/) :param code_mirror: Flag to enable or disable inclusion of Code Mirror (https://codemirror.net/)
Defaults to True. Defaults to True.
:param sortable: Flag to enable or disable inclusion of SortableJS (https://sortablejs.github.io/Sortable/).
Defaults to False.
:param protect_routes: Flag to enable or disable routes protection based on authentication. :param protect_routes: Flag to enable or disable routes protection based on authentication.
Defaults to True. Defaults to True.
:param mount_auth_app: Flag to enable or disable mounting of authentication routes. :param mount_auth_app: Flag to enable or disable mounting of authentication routes.
@@ -117,6 +121,9 @@ def create_app(daisyui: Optional[bool] = True,
if code_mirror: if code_mirror:
hdrs += include_assets("codemirror", order=["codemirror"]) hdrs += include_assets("codemirror", order=["codemirror"])
if sortable:
hdrs += include_assets("sortableJs")
beforeware = create_auth_beforeware() if protect_routes else None beforeware = create_auth_beforeware() if protect_routes else None
app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs) app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs)

View File

@@ -276,10 +276,13 @@ def _get_attr(x, attr):
if isinstance(x, NotStr) and attr == "s": if isinstance(x, NotStr) and attr == "s":
# Special case for NotStr: return the name of the svg # Special case for NotStr: return the name of the svg
svg = getattr(x, attr, MISSING_ATTR) attr_value = getattr(x, attr, MISSING_ATTR)
match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', svg) if attr_value.strip().startswith("<svg "):
if match: match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', attr_value)
return f'<svg name="{match.group(1)}" />' if match:
return f'<svg name="{match.group(1)}" />'
else:
return attr_value
return getattr(x, attr, MISSING_ATTR) return getattr(x, attr, MISSING_ATTR)

View File

@@ -1,5 +1,6 @@
import pandas as pd import pandas as pd
import pytest import pytest
from fastcore.basics import NotStr
from fasthtml.components import Div, Script from fasthtml.components import Div, Script
from myfasthtml.controls.DataGrid import DataGrid, DatagridConf from myfasthtml.controls.DataGrid import DataGrid, DatagridConf
@@ -785,6 +786,22 @@ class TestDataGridRender:
# Body rows # Body rows
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def test_i_can_render_body(self, datagrid_with_data):
"""Test that the body renders with the correct ID and CSS class.
"""
dg = datagrid_with_data
html = dg.mk_body_wrapper()
expected = Div(
Div(
Div(cls="dt2-row"),
Div(cls="dt2-row"),
Div(cls="dt2-row"),
cls=Contains("dt2-body")),
id=f"tb_{dg._id}",
cls=Contains("dt2-body-container"),
)
assert matches(html, expected)
def test_i_can_render_body_row_count(self, datagrid_with_full_data): def test_i_can_render_body_row_count(self, datagrid_with_full_data):
"""Test that mk_body_content_page returns one row per DataFrame row plus the add-row button. """Test that mk_body_content_page returns one row per DataFrame row plus the add-row button.
@@ -922,13 +939,13 @@ class TestDataGridRender:
# Body cell content # Body cell content
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@pytest.mark.parametrize("col_title, expected_css_class, expected_value", [ @pytest.mark.parametrize("col_title, expected_value", [
("name", "dt2-cell-content-text", "Alice"), ("name", '<span class="dt2-cell-content-text truncate">Alice</span>'),
("age", "dt2-cell-content-number", "25"), ("age", '<span class="dt2-cell-content-number truncate">25</span>'),
("active", "dt2-cell-content-checkbox", None), ("active", '<div class="dt2-cell-content-checkbox">'),
]) ])
def test_i_can_render_body_cell_content_for_column_type( def test_i_can_render_body_cell_content_for_column_type(
self, datagrid_with_full_data, col_title, expected_css_class, expected_value): self, datagrid_with_full_data, col_title, expected_value):
"""Test that cell content carries the correct CSS class and value for each column type. """Test that cell content carries the correct CSS class and value for each column type.
Why these elements matter: Why these elements matter:
@@ -945,11 +962,4 @@ class TestDataGridRender:
col_pos = dg._columns.index(col_def) col_pos = dg._columns.index(col_def)
content = dg.mk_body_cell_content(col_pos, 0, col_def, None) content = dg.mk_body_cell_content(col_pos, 0, col_def, None)
assert matches(content, NotStr(expected_value))
assert expected_css_class in str(content), (
f"Expected CSS class '{expected_css_class}' in cell content for column '{col_title}'"
)
if expected_value is not None:
assert expected_value in str(content), (
f"Expected value '{expected_value}' in cell content for column '{col_title}'"
)

View File

@@ -6,8 +6,9 @@ from fasthtml.common import Div, FT, Input, Form, Fieldset, Select
from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager
from myfasthtml.controls.Search import Search from myfasthtml.controls.Search import Search
from myfasthtml.controls.datagrid_objects import DataGridColumnState from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridColumnUiState
from myfasthtml.core.constants import ColumnType from myfasthtml.core.constants import ColumnType
from myfasthtml.core.data.ColumnDefinition import ColumnDefinition
from myfasthtml.core.instances import InstancesManager, MultipleInstance from myfasthtml.core.instances import InstancesManager, MultipleInstance
from myfasthtml.test.matcher import ( from myfasthtml.test.matcher import (
matches, find_one, find, Contains, TestIcon, TestObject matches, find_one, find, Contains, TestIcon, TestObject
@@ -41,13 +42,18 @@ class MockDataGrid(MultipleInstance):
return None return None
# col_def: ColumnDefinition, col_ui_state: DataGridColumnUiState
@pytest.fixture @pytest.fixture
def mock_datagrid(root_instance): def mock_datagrid(root_instance):
"""Create a mock DataGrid with sample columns.""" """Create a mock DataGrid with sample columns."""
columns = [ columns = [
DataGridColumnState(col_id="name", col_index=0, title="Name", type=ColumnType.Text, visible=True), DataGridColumnState(ColumnDefinition(col_id="name", col_index=0, title="Name", type=ColumnType.Text),
DataGridColumnState(col_id="age", col_index=1, title="Age", type=ColumnType.Number, visible=True), DataGridColumnUiState(col_id="name", visible=True)),
DataGridColumnState(col_id="email", col_index=2, title="Email", type=ColumnType.Text, visible=False), DataGridColumnState(ColumnDefinition(col_id="age", col_index=1, title="Age", type=ColumnType.Number),
DataGridColumnUiState(col_id="age", visible=True)),
DataGridColumnState(ColumnDefinition(col_id="email", col_index=2, title="Email", type=ColumnType.Text),
DataGridColumnUiState(col_id="email", visible=False)),
] ]
yield MockDataGrid(root_instance, columns=columns, _id="test-datagrid") yield MockDataGrid(root_instance, columns=columns, _id="test-datagrid")
InstancesManager.reset() InstancesManager.reset()

View File

@@ -9,12 +9,14 @@ import pytest
from myfasthtml.controls.DataGrid import DataGrid from myfasthtml.controls.DataGrid import DataGrid
from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor
from myfasthtml.controls.DataGridsManager import DataGridsManager from myfasthtml.controls.DataGridsManager import DataGridsManager
from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridRowUiState from myfasthtml.controls.datagrid_objects import DataGridColumnState, DataGridColumnUiState
from myfasthtml.core.constants import ColumnType from myfasthtml.core.constants import ColumnType
from myfasthtml.core.data.ColumnDefinition import ColumnDefinition
from myfasthtml.core.formatting.dataclasses import FormatRule, Style from myfasthtml.core.formatting.dataclasses import FormatRule, Style
from myfasthtml.core.formatting.dsl.definition import FormattingDSL from myfasthtml.core.formatting.dsl.definition import FormattingDSL
from myfasthtml.core.instances import InstancesManager from myfasthtml.core.instances import InstancesManager
@pytest.fixture @pytest.fixture
def manager(root_instance): def manager(root_instance):
"""Create a DataGridsManager instance.""" """Create a DataGridsManager instance."""
@@ -27,19 +29,16 @@ def manager(root_instance):
def datagrid(manager): def datagrid(manager):
"""Create a DataGrid instance.""" """Create a DataGrid instance."""
from myfasthtml.controls.DataGrid import DatagridConf from myfasthtml.controls.DataGrid import DatagridConf
conf = DatagridConf(namespace="app", name="products", id="test-grid") conf = DatagridConf(namespace="app", name="products")
grid = DataGrid(manager, conf=conf, save_state=False, _id="test-datagrid") grid = DataGrid(manager, conf=conf, save_state=False, _id="mf-data_grid-test-datagrid")
# ColumnDefinition, col_ui_state: DataGridColumnUiState
# Add some columns # Add some columns
grid._state.columns = [ grid.columns = [
DataGridColumnState(col_id="amount", col_index=0, title="Amount", type=ColumnType.Number, visible=True), DataGridColumnState(ColumnDefinition(col_id="amount", col_index=0, title="Amount", type=ColumnType.Number),
DataGridColumnState(col_id="status", col_index=1, title="Status", type=ColumnType.Text, visible=True), DataGridColumnUiState(col_id="amount", visible=True)),
] DataGridColumnState(ColumnDefinition(col_id="status", col_index=1, title="Status", type=ColumnType.Text),
DataGridColumnUiState(col_id="status", visible=True))
# Add some rows
grid._state.rows = [
DataGridRowUiState(0),
DataGridRowUiState(1),
] ]
yield grid yield grid