Reimplementing Columns Management
This commit is contained in:
@@ -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
|
||||||
```
|
```
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
2
src/myfasthtml/assets/sortableJs/Sortable.min.js
vendored
Normal file
2
src/myfasthtml/assets/sortableJs/Sortable.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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)
|
||||||
|
|||||||
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:
|
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():
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
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()
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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}'"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user