From e01d2cd74b66ca49433252485c3f3e7e95a1e749 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sun, 8 Mar 2026 12:03:07 +0100 Subject: [PATCH] Reimplementing Columns Management --- src/myfasthtml/assets/Readme.md | 9 +- src/myfasthtml/assets/core/search.css | 10 +- .../assets/sortableJs/Sortable.min.js | 2 + src/myfasthtml/controls/DataGrid.py | 42 ++++++--- .../controls/DataGridColumnsList.py | 94 +++++++++++++++++++ src/myfasthtml/controls/IconsHelper.py | 5 +- src/myfasthtml/controls/Search.py | 47 +++++++--- src/myfasthtml/controls/Sortable.py | 91 ++++++++++++++++++ .../core/data/DataServicesManager.py | 5 +- src/myfasthtml/core/dbmanager.py | 4 +- src/myfasthtml/core/instances.py | 2 +- src/myfasthtml/myfastapp.py | 15 ++- src/myfasthtml/test/matcher.py | 13 ++- tests/controls/test_datagrid.py | 36 ++++--- .../controls/test_datagrid_columns_manager.py | 14 ++- tests/controls/test_datagrid_formatting.py | 23 +++-- 16 files changed, 336 insertions(+), 76 deletions(-) create mode 100644 src/myfasthtml/assets/sortableJs/Sortable.min.js create mode 100644 src/myfasthtml/controls/DataGridColumnsList.py create mode 100644 src/myfasthtml/controls/Sortable.py diff --git a/src/myfasthtml/assets/Readme.md b/src/myfasthtml/assets/Readme.md index 34a0c5b..d7be089 100644 --- a/src/myfasthtml/assets/Readme.md +++ b/src/myfasthtml/assets/Readme.md @@ -1,9 +1,8 @@ # Commands used + ``` -cd src/myfasthtml/assets - # 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.css 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.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 ``` \ No newline at end of file diff --git a/src/myfasthtml/assets/core/search.css b/src/myfasthtml/assets/core/search.css index 5aa049f..cc04d41 100644 --- a/src/myfasthtml/assets/core/search.css +++ b/src/myfasthtml/assets/core/search.css @@ -1,5 +1,11 @@ +.mf-search { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; +} + .mf-search-results { margin-top: 0.5rem; - /*max-height: 400px;*/ - overflow: auto; + overflow-y: auto; + min-height: 0; } diff --git a/src/myfasthtml/assets/sortableJs/Sortable.min.js b/src/myfasthtml/assets/sortableJs/Sortable.min.js new file mode 100644 index 0000000..95423a6 --- /dev/null +++ b/src/myfasthtml/assets/sortableJs/Sortable.min.js @@ -0,0 +1,2 @@ +/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;tt.length)&&(e=t.length);for(var n=0,o=new Array(e);n"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i=n.left-e&&i<=n.right+e,e=r>=n.top-e&&r<=n.bottom+e;return o&&e?a=t:void 0}}),a);if(e){var n,o={};for(n in t)t.hasOwnProperty(n)&&(o[n]=t[n]);o.target=o.rootEl=e,o.preventDefault=void 0,o.stopPropagation=void 0,e[K]._onDragOver(o)}}var i,r,a}function Ft(t){Z&&Z.parentNode[K]._isOutsideThisEl(t.target)}function jt(t,e){if(!t||!t.nodeType||1!==t.nodeType)throw"Sortable: `el` must be an HTMLElement, not ".concat({}.toString.call(t));this.el=t,this.options=e=a({},e),t[K]=this;var n,o,i={group:null,sort:!0,disabled:!1,store:null,handle:null,draggable:/^[uo]l$/i.test(t.nodeName)?">li":">*",swapThreshold:1,invertSwap:!1,invertedSwapThreshold:null,removeCloneOnHide:!0,direction:function(){return kt(t,this.options)},ghostClass:"sortable-ghost",chosenClass:"sortable-chosen",dragClass:"sortable-drag",ignore:"a, img",filter:null,preventOnFilter:!0,animation:0,easing:null,setData:function(t,e){t.setData("Text",e.textContent)},dropBubble:!1,dragoverBubble:!1,dataIdAttr:"data-id",delay:0,delayOnTouchOnly:!1,touchStartThreshold:(Number.parseInt?Number:window).parseInt(window.devicePixelRatio,10)||1,forceFallback:!1,fallbackClass:"sortable-fallback",fallbackOnBody:!1,fallbackTolerance:0,fallbackOffset:{x:0,y:0},supportPointer:!1!==jt.supportPointer&&"PointerEvent"in window&&(!u||c),emptyInsertThreshold:5};for(n in z.initializePlugins(this,t,i),i)n in e||(e[n]=i[n]);for(o in Rt(e),this)"_"===o.charAt(0)&&"function"==typeof this[o]&&(this[o]=this[o].bind(this));this.nativeDraggable=!e.forceFallback&&It,this.nativeDraggable&&(this.options.touchStartThreshold=1),e.supportPointer?h(t,"pointerdown",this._onTapStart):(h(t,"mousedown",this._onTapStart),h(t,"touchstart",this._onTapStart)),this.nativeDraggable&&(h(t,"dragover",this),h(t,"dragenter",this)),St.push(this.el),e.store&&e.store.get&&this.sort(e.store.get(this)||[]),a(this,A())}function Ht(t,e,n,o,i,r,a,l){var s,c,u=t[K],d=u.options.onMove;return!window.CustomEvent||y||w?(s=document.createEvent("Event")).initEvent("move",!0,!0):s=new CustomEvent("move",{bubbles:!0,cancelable:!0}),s.to=e,s.from=t,s.dragged=n,s.draggedRect=o,s.related=i||e,s.relatedRect=r||X(e),s.willInsertAfter=l,s.originalEvent=a,t.dispatchEvent(s),c=d?d.call(u,s,a):c}function Lt(t){t.draggable=!1}function Kt(){xt=!1}function Wt(t){return setTimeout(t,0)}function zt(t){return clearTimeout(t)}jt.prototype={constructor:jt,_isOutsideThisEl:function(t){this.el.contains(t)||t===this.el||(vt=null)},_getDirection:function(t,e){return"function"==typeof this.options.direction?this.options.direction.call(this,t,e,Z):this.options.direction},_onTapStart:function(e){if(e.cancelable){var n=this,o=this.el,t=this.options,i=t.preventOnFilter,r=e.type,a=e.touches&&e.touches[0]||e.pointerType&&"touch"===e.pointerType&&e,l=(a||e).target,s=e.target.shadowRoot&&(e.path&&e.path[0]||e.composedPath&&e.composedPath()[0])||l,c=t.filter;if(!function(t){Ot.length=0;var e=t.getElementsByTagName("input"),n=e.length;for(;n--;){var o=e[n];o.checked&&Ot.push(o)}}(o),!Z&&!(/mousedown|pointerdown/.test(r)&&0!==e.button||t.disabled)&&!s.isContentEditable&&(this.nativeDraggable||!u||!l||"SELECT"!==l.tagName.toUpperCase())&&!((l=P(l,t.draggable,o,!1))&&l.animated||et===l)){if(it=j(l),at=j(l,t.draggable),"function"==typeof c){if(c.call(this,e,l,this))return V({sortable:n,rootEl:s,name:"filter",targetEl:l,toEl:o,fromEl:o}),U("filter",n,{evt:e}),void(i&&e.preventDefault())}else if(c=c&&c.split(",").some(function(t){if(t=P(s,t.trim(),o,!1))return V({sortable:n,rootEl:t,name:"filter",targetEl:l,fromEl:o,toEl:o}),U("filter",n,{evt:e}),!0}))return void(i&&e.preventDefault());t.handle&&!P(s,t.handle,o,!1)||this._prepareDragStart(e,a,l)}}},_prepareDragStart:function(t,e,n){var o,i=this,r=i.el,a=i.options,l=r.ownerDocument;n&&!Z&&n.parentNode===r&&(o=X(n),J=r,$=(Z=n).parentNode,tt=Z.nextSibling,et=n,st=a.group,ut={target:jt.dragged=Z,clientX:(e||t).clientX,clientY:(e||t).clientY},ft=ut.clientX-o.left,gt=ut.clientY-o.top,this._lastX=(e||t).clientX,this._lastY=(e||t).clientY,Z.style["will-change"]="all",o=function(){U("delayEnded",i,{evt:t}),jt.eventCanceled?i._onDrop():(i._disableDelayedDragEvents(),!s&&i.nativeDraggable&&(Z.draggable=!0),i._triggerDragStart(t,e),V({sortable:i,name:"choose",originalEvent:t}),k(Z,a.chosenClass,!0))},a.ignore.split(",").forEach(function(t){D(Z,t.trim(),Lt)}),h(l,"dragover",Bt),h(l,"mousemove",Bt),h(l,"touchmove",Bt),a.supportPointer?(h(l,"pointerup",i._onDrop),this.nativeDraggable||h(l,"pointercancel",i._onDrop)):(h(l,"mouseup",i._onDrop),h(l,"touchend",i._onDrop),h(l,"touchcancel",i._onDrop)),s&&this.nativeDraggable&&(this.options.touchStartThreshold=4,Z.draggable=!0),U("delayStart",this,{evt:t}),!a.delay||a.delayOnTouchOnly&&!e||this.nativeDraggable&&(w||y)?o():jt.eventCanceled?this._onDrop():(a.supportPointer?(h(l,"pointerup",i._disableDelayedDrag),h(l,"pointercancel",i._disableDelayedDrag)):(h(l,"mouseup",i._disableDelayedDrag),h(l,"touchend",i._disableDelayedDrag),h(l,"touchcancel",i._disableDelayedDrag)),h(l,"mousemove",i._delayedDragTouchMoveHandler),h(l,"touchmove",i._delayedDragTouchMoveHandler),a.supportPointer&&h(l,"pointermove",i._delayedDragTouchMoveHandler),i._dragStartTimer=setTimeout(o,a.delay)))},_delayedDragTouchMoveHandler:function(t){t=t.touches?t.touches[0]:t;Math.max(Math.abs(t.clientX-this._lastX),Math.abs(t.clientY-this._lastY))>=Math.floor(this.options.touchStartThreshold/(this.nativeDraggable&&window.devicePixelRatio||1))&&this._disableDelayedDrag()},_disableDelayedDrag:function(){Z&&Lt(Z),clearTimeout(this._dragStartTimer),this._disableDelayedDragEvents()},_disableDelayedDragEvents:function(){var t=this.el.ownerDocument;p(t,"mouseup",this._disableDelayedDrag),p(t,"touchend",this._disableDelayedDrag),p(t,"touchcancel",this._disableDelayedDrag),p(t,"pointerup",this._disableDelayedDrag),p(t,"pointercancel",this._disableDelayedDrag),p(t,"mousemove",this._delayedDragTouchMoveHandler),p(t,"touchmove",this._delayedDragTouchMoveHandler),p(t,"pointermove",this._delayedDragTouchMoveHandler)},_triggerDragStart:function(t,e){e=e||"touch"==t.pointerType&&t,!this.nativeDraggable||e?this.options.supportPointer?h(document,"pointermove",this._onTouchMove):h(document,e?"touchmove":"mousemove",this._onTouchMove):(h(Z,"dragend",this),h(J,"dragstart",this._onDragStart));try{document.selection?Wt(function(){document.selection.empty()}):window.getSelection().removeAllRanges()}catch(t){}},_dragStarted:function(t,e){var n;Dt=!1,J&&Z?(U("dragStarted",this,{evt:e}),this.nativeDraggable&&h(document,"dragover",Ft),n=this.options,t||k(Z,n.dragClass,!1),k(Z,n.ghostClass,!0),jt.active=this,t&&this._appendGhost(),V({sortable:this,name:"start",originalEvent:e})):this._nulling()},_emulateDragOver:function(){if(dt){this._lastX=dt.clientX,this._lastY=dt.clientY,Xt();for(var t=document.elementFromPoint(dt.clientX,dt.clientY),e=t;t&&t.shadowRoot&&(t=t.shadowRoot.elementFromPoint(dt.clientX,dt.clientY))!==e;)e=t;if(Z.parentNode[K]._isOutsideThisEl(t),e)do{if(e[K])if(e[K]._onDragOver({clientX:dt.clientX,clientY:dt.clientY,target:t,rootEl:e})&&!this.options.dragoverBubble)break}while(e=g(t=e));Yt()}},_onTouchMove:function(t){if(ut){var e=this.options,n=e.fallbackTolerance,o=e.fallbackOffset,i=t.touches?t.touches[0]:t,r=Q&&b(Q,!0),a=Q&&r&&r.a,l=Q&&r&&r.d,e=At&&wt&&E(wt),a=(i.clientX-ut.clientX+o.x)/(a||1)+(e?e[0]-Tt[0]:0)/(a||1),l=(i.clientY-ut.clientY+o.y)/(l||1)+(e?e[1]-Tt[1]:0)/(l||1);if(!jt.active&&!Dt){if(n&&Math.max(Math.abs(i.clientX-this._lastX),Math.abs(i.clientY-this._lastY))E.right+10||S.clientY>x.bottom&&S.clientX>x.left:S.clientY>E.bottom+10||S.clientX>x.right&&S.clientY>x.top)||m.animated)){if(m&&(t=n,e=r,C=X(B((_=this).el,0,_.options,!0)),_=L(_.el,_.options,Q),e?t.clientX<_.left-10||t.clientY 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) diff --git a/src/myfasthtml/controls/DataGridColumnsList.py b/src/myfasthtml/controls/DataGridColumnsList.py new file mode 100644 index 0000000..b821b4a --- /dev/null +++ b/src/myfasthtml/controls/DataGridColumnsList.py @@ -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() diff --git a/src/myfasthtml/controls/IconsHelper.py b/src/myfasthtml/controls/IconsHelper.py index 3d5d382..10be1c7 100644 --- a/src/myfasthtml/controls/IconsHelper.py +++ b/src/myfasthtml/controls/IconsHelper.py @@ -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(): diff --git a/src/myfasthtml/controls/Search.py b/src/myfasthtml/controls/Search.py index ce05ea2..76af35f 100644 --- a/src/myfasthtml/controls/Search.py +++ b/src/myfasthtml/controls/Search.py @@ -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): diff --git a/src/myfasthtml/controls/Sortable.py b/src/myfasthtml/controls/Sortable.py new file mode 100644 index 0000000..5d95dac --- /dev/null +++ b/src/myfasthtml/controls/Sortable.py @@ -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() diff --git a/src/myfasthtml/core/data/DataServicesManager.py b/src/myfasthtml/core/data/DataServicesManager.py index 7e6e168..e59a9c4 100644 --- a/src/myfasthtml/core/data/DataServicesManager.py +++ b/src/myfasthtml/core/data/DataServicesManager.py @@ -5,7 +5,7 @@ from myfasthtml.core.data.DataService import DataService from myfasthtml.core.formula.engine import FormulaEngine from myfasthtml.core.instances import SingleInstance -logger = logging.getLogger(__name__) +logger = logging.getLogger("DataServicesManager") class DataServicesManager(SingleInstance): @@ -75,12 +75,13 @@ class DataServicesManager(SingleInstance): Returns: The restored DataService instance. """ + logger.debug(f"restore_service {grid_id=}") + if grid_id in self._services: return self._services[grid_id] service = DataService(self, _id=grid_id) self._services[grid_id] = service - logger.debug("DataService restored for grid_id=%s", grid_id) return service def remove_service(self, grid_id: str) -> None: diff --git a/src/myfasthtml/core/dbmanager.py b/src/myfasthtml/core/dbmanager.py index 2a7a603..3177e99 100644 --- a/src/myfasthtml/core/dbmanager.py +++ b/src/myfasthtml/core/dbmanager.py @@ -47,8 +47,8 @@ class DbObject: def __init__(self, owner: BaseInstance, name=None, db_manager=None, save_state=True): self._owner = owner self._name = name or owner.get_id() - if self._name.startswith(("#", "-")) and owner.get_parent() is not None: - self._name = owner.get_parent().get_id() + self._name + if self._name.startswith(("#", "-")) and owner is not None: + self._name = owner.get_id() + self._name self._db_manager = db_manager or DbManager(self._owner) self._save_state = save_state diff --git a/src/myfasthtml/core/instances.py b/src/myfasthtml/core/instances.py index 30c1d06..8803208 100644 --- a/src/myfasthtml/core/instances.py +++ b/src/myfasthtml/core/instances.py @@ -55,7 +55,7 @@ class BaseInstance: if key in InstancesManager.instances: res = InstancesManager.instances[key] 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: logger.debug(f" instance {_id} already exists, returning existing instance") diff --git a/src/myfasthtml/myfastapp.py b/src/myfasthtml/myfastapp.py index 4677458..6fb8959 100644 --- a/src/myfasthtml/myfastapp.py +++ b/src/myfasthtml/myfastapp.py @@ -78,6 +78,7 @@ def include_assets(module_name: str, order: Optional[List[str]] = None) -> list: def create_app(daisyui: Optional[bool] = True, vis: Optional[bool] = True, code_mirror: Optional[bool] = True, + sortable: Optional[bool] = True, protect_routes: Optional[bool] = True, mount_auth_app: Optional[bool] = False, base_url: Optional[str] = None, @@ -85,16 +86,19 @@ def create_app(daisyui: Optional[bool] = True, """ Creates and configures a FastHtml application with optional support for daisyUI themes and authentication routes. - + :param daisyui: Flag to enable or disable inclusion of daisyUI (https://daisyui.com/). Defaults to True. - + :param vis: Flag to enable or disable inclusion of Vis network (https://visjs.org/) Defaults to True. - + :param code_mirror: Flag to enable or disable inclusion of Code Mirror (https://codemirror.net/) 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. Defaults to True. :param mount_auth_app: Flag to enable or disable mounting of authentication routes. @@ -116,6 +120,9 @@ def create_app(daisyui: Optional[bool] = True, if code_mirror: hdrs += include_assets("codemirror", order=["codemirror"]) + + if sortable: + hdrs += include_assets("sortableJs") beforeware = create_auth_beforeware() if protect_routes else None app, rt = fasthtml.fastapp.fast_app(before=beforeware, hdrs=tuple(hdrs), **kwargs) diff --git a/src/myfasthtml/test/matcher.py b/src/myfasthtml/test/matcher.py index 2199966..1581a19 100644 --- a/src/myfasthtml/test/matcher.py +++ b/src/myfasthtml/test/matcher.py @@ -276,11 +276,14 @@ def _get_attr(x, attr): if isinstance(x, NotStr) and attr == "s": # Special case for NotStr: return the name of the svg - svg = getattr(x, attr, MISSING_ATTR) - match = re.search(r'name\s*=\s*["\']([^"\']+)["\']', svg) - if match: - return f'' - + attr_value = getattr(x, attr, MISSING_ATTR) + if attr_value.strip().startswith("' + else: + return attr_value + return getattr(x, attr, MISSING_ATTR) diff --git a/tests/controls/test_datagrid.py b/tests/controls/test_datagrid.py index 414ccf8..29922f9 100644 --- a/tests/controls/test_datagrid.py +++ b/tests/controls/test_datagrid.py @@ -1,5 +1,6 @@ import pandas as pd import pytest +from fastcore.basics import NotStr from fasthtml.components import Div, Script from myfasthtml.controls.DataGrid import DataGrid, DatagridConf @@ -785,6 +786,22 @@ class TestDataGridRender: # 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): """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 # ------------------------------------------------------------------ - @pytest.mark.parametrize("col_title, expected_css_class, expected_value", [ - ("name", "dt2-cell-content-text", "Alice"), - ("age", "dt2-cell-content-number", "25"), - ("active", "dt2-cell-content-checkbox", None), + @pytest.mark.parametrize("col_title, expected_value", [ + ("name", 'Alice'), + ("age", '25'), + ("active", '
'), ]) 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. Why these elements matter: @@ -945,11 +962,4 @@ class TestDataGridRender: col_pos = dg._columns.index(col_def) content = dg.mk_body_cell_content(col_pos, 0, col_def, None) - - 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}'" - ) + assert matches(content, NotStr(expected_value)) diff --git a/tests/controls/test_datagrid_columns_manager.py b/tests/controls/test_datagrid_columns_manager.py index e206f48..56c2202 100644 --- a/tests/controls/test_datagrid_columns_manager.py +++ b/tests/controls/test_datagrid_columns_manager.py @@ -6,8 +6,9 @@ from fasthtml.common import Div, FT, Input, Form, Fieldset, Select from myfasthtml.controls.DataGridColumnsManager import DataGridColumnsManager 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.data.ColumnDefinition import ColumnDefinition from myfasthtml.core.instances import InstancesManager, MultipleInstance from myfasthtml.test.matcher import ( matches, find_one, find, Contains, TestIcon, TestObject @@ -41,13 +42,18 @@ class MockDataGrid(MultipleInstance): return None +# col_def: ColumnDefinition, col_ui_state: DataGridColumnUiState + @pytest.fixture def mock_datagrid(root_instance): """Create a mock DataGrid with sample columns.""" columns = [ - DataGridColumnState(col_id="name", col_index=0, title="Name", type=ColumnType.Text, visible=True), - DataGridColumnState(col_id="age", col_index=1, title="Age", type=ColumnType.Number, visible=True), - DataGridColumnState(col_id="email", col_index=2, title="Email", type=ColumnType.Text, visible=False), + DataGridColumnState(ColumnDefinition(col_id="name", col_index=0, title="Name", type=ColumnType.Text), + DataGridColumnUiState(col_id="name", visible=True)), + 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") InstancesManager.reset() diff --git a/tests/controls/test_datagrid_formatting.py b/tests/controls/test_datagrid_formatting.py index b80bb79..3a283a5 100644 --- a/tests/controls/test_datagrid_formatting.py +++ b/tests/controls/test_datagrid_formatting.py @@ -9,12 +9,14 @@ import pytest from myfasthtml.controls.DataGrid import DataGrid from myfasthtml.controls.DataGridFormattingEditor import DataGridFormattingEditor 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.data.ColumnDefinition import ColumnDefinition from myfasthtml.core.formatting.dataclasses import FormatRule, Style from myfasthtml.core.formatting.dsl.definition import FormattingDSL from myfasthtml.core.instances import InstancesManager + @pytest.fixture def manager(root_instance): """Create a DataGridsManager instance.""" @@ -27,19 +29,16 @@ def manager(root_instance): def datagrid(manager): """Create a DataGrid instance.""" from myfasthtml.controls.DataGrid import DatagridConf - conf = DatagridConf(namespace="app", name="products", id="test-grid") - grid = DataGrid(manager, conf=conf, save_state=False, _id="test-datagrid") + conf = DatagridConf(namespace="app", name="products") + grid = DataGrid(manager, conf=conf, save_state=False, _id="mf-data_grid-test-datagrid") + # ColumnDefinition, col_ui_state: DataGridColumnUiState # Add some columns - grid._state.columns = [ - DataGridColumnState(col_id="amount", col_index=0, title="Amount", type=ColumnType.Number, visible=True), - DataGridColumnState(col_id="status", col_index=1, title="Status", type=ColumnType.Text, visible=True), - ] - - # Add some rows - grid._state.rows = [ - DataGridRowUiState(0), - DataGridRowUiState(1), + grid.columns = [ + DataGridColumnState(ColumnDefinition(col_id="amount", col_index=0, title="Amount", type=ColumnType.Number), + 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)) ] yield grid