Reimplementing Columns Management
This commit is contained in:
@@ -10,7 +10,7 @@ from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.BaseCommands import BaseCommands
|
||||
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.DslEditor import DslEditorConf
|
||||
from myfasthtml.controls.IconsHelper import IconsHelper
|
||||
@@ -259,9 +259,9 @@ class DataGrid(MultipleInstance):
|
||||
self.change_selection_mode()
|
||||
|
||||
# add columns manager
|
||||
self._columns_manager = DataGridColumnsManager(self)
|
||||
self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed())
|
||||
self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed())
|
||||
self._columns_list = DataGridColumnsList(self)
|
||||
# self._columns_manager.bind_command("ToggleColumn", self.commands.on_column_changed())
|
||||
# self._columns_manager.bind_command("SaveColumnDetails", self.commands.on_column_changed())
|
||||
|
||||
if self._settings.enable_formatting:
|
||||
provider = InstancesManager.get_by_type(self._session, DatagridMetadataProvider, None)
|
||||
@@ -494,6 +494,22 @@ class DataGrid(MultipleInstance):
|
||||
|
||||
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:
|
||||
"""
|
||||
Calculate optimal width for a column based on content.
|
||||
@@ -612,19 +628,16 @@ class DataGrid(MultipleInstance):
|
||||
def handle_toggle_columns_manager(self):
|
||||
logger.debug(f"toggle_columns_manager")
|
||||
self._panel.set_title(side="right", title="Columns")
|
||||
self._columns_manager.adding_new_column = False
|
||||
self._columns_manager.set_all_columns(True)
|
||||
self._columns_manager.unbind_command(("ShowAllColumns", "SaveColumnDetails"))
|
||||
self._panel.set_right(self._columns_manager)
|
||||
self._panel.set_right(self._columns_list)
|
||||
|
||||
def handle_toggle_new_column_editor(self):
|
||||
logger.debug(f"handle_toggle_new_column_editor")
|
||||
self._panel.set_title(side="right", title="Columns")
|
||||
self._columns_manager.adding_new_column = True
|
||||
self._columns_manager.set_all_columns(False)
|
||||
self._columns_manager.bind_command(("ShowAllColumns", "SaveColumnDetails"),
|
||||
self._panel.commands.set_side_visible("right", False))
|
||||
self._panel.set_right(self._columns_manager)
|
||||
self._columns_list.adding_new_column = True
|
||||
self._columns_list.set_all_columns(False)
|
||||
self._columns_list.bind_command(("ShowAllColumns", "SaveColumnDetails"),
|
||||
self._panel.commands.set_side_visible("right", False))
|
||||
self._panel.set_right(self._columns_list)
|
||||
|
||||
def handle_toggle_formatting_editor(self):
|
||||
logger.debug(f"toggle_formatting_editor")
|
||||
@@ -670,6 +683,9 @@ class DataGrid(MultipleInstance):
|
||||
manager = InstancesManager.get_by_type(self._session, DataServicesManager, None)
|
||||
return manager.get_formula_engine() if manager is not None else None
|
||||
|
||||
def get_columns(self):
|
||||
return self._columns
|
||||
|
||||
@staticmethod
|
||||
def get_grid_id_from_data_service_id(data_service_id):
|
||||
return data_service_id.replace(DataService.compute_prefix(), DataGrid.compute_prefix(), 1)
|
||||
|
||||
94
src/myfasthtml/controls/DataGridColumnsList.py
Normal file
94
src/myfasthtml/controls/DataGridColumnsList.py
Normal 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()
|
||||
@@ -54,6 +54,9 @@ class IconsHelper:
|
||||
if name in IconsHelper._icons:
|
||||
return IconsHelper._icons[name]
|
||||
|
||||
if not isinstance(name, str):
|
||||
return question20_regular
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
import myfasthtml.icons as icons_pkg
|
||||
@@ -76,7 +79,7 @@ class IconsHelper:
|
||||
IconsHelper._icons[name] = icon
|
||||
return icon
|
||||
|
||||
return None
|
||||
return question20_regular
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
|
||||
@@ -14,12 +14,13 @@ logger = logging.getLogger("Search")
|
||||
|
||||
class Commands(BaseCommands):
|
||||
def search(self):
|
||||
return (Command("Search",
|
||||
f"Search {self._owner.items_names}",
|
||||
self._owner,
|
||||
self._owner.on_search).htmx(target=f"#{self._owner.get_id()}-results",
|
||||
trigger="keyup changed delay:300ms",
|
||||
swap="innerHTML"))
|
||||
return Command("Search",
|
||||
f"Search {self._owner.items_names}",
|
||||
self._owner,
|
||||
self._owner.on_search).htmx(target=f"#{self._owner.get_id()}-results",
|
||||
trigger="keyup changed delay:300ms",
|
||||
swap="innerHTML",
|
||||
auto_swap_oob=False)
|
||||
|
||||
|
||||
class Search(MultipleInstance):
|
||||
@@ -45,6 +46,7 @@ class Search(MultipleInstance):
|
||||
items_names=None, # what is the name of the 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_id: Callable[[Any], str] = None, # use for deduplication
|
||||
template: Callable[[Any], Any] = None, # once filtered, what to render ?
|
||||
max_height: int = 400):
|
||||
"""
|
||||
@@ -65,6 +67,7 @@ class Search(MultipleInstance):
|
||||
self.items = items or []
|
||||
self.filtered = self.items.copy()
|
||||
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.commands = Commands(self)
|
||||
self.max_height = max_height
|
||||
@@ -86,6 +89,7 @@ class Search(MultipleInstance):
|
||||
return tuple(self._mk_search_results())
|
||||
|
||||
def search(self, query):
|
||||
|
||||
logger.debug(f"search {query=}")
|
||||
if query is None or query.strip() == "":
|
||||
self.filtered = self.items.copy()
|
||||
@@ -93,24 +97,39 @@ class Search(MultipleInstance):
|
||||
else:
|
||||
res_seq = subsequence_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
|
||||
|
||||
def _mk_search_results(self):
|
||||
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):
|
||||
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()),
|
||||
Div(
|
||||
*self._mk_search_results(),
|
||||
id=f"{self._id}-results",
|
||||
cls="mf-search-results",
|
||||
style="max-height: 400px;" if self.max_height else None
|
||||
),
|
||||
Div(*self._mk_search_results(),
|
||||
id=f"{self._id}-results",
|
||||
cls="mf-search-results"),
|
||||
id=f"{self._id}",
|
||||
cls="mf-search",
|
||||
style=f"max-height: {self.max_height}px;" if self.max_height else None
|
||||
)
|
||||
|
||||
def __ft__(self):
|
||||
|
||||
91
src/myfasthtml/controls/Sortable.py
Normal file
91
src/myfasthtml/controls/Sortable.py
Normal 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()
|
||||
Reference in New Issue
Block a user