feat: add multi-column sorting (frontend or backend) for grids
This commit is contained in:
		
							parent
							
								
									58f7a862a2
								
							
						
					
					
						commit
						8d6f4ad368
					
				
					 4 changed files with 388 additions and 93 deletions
				
			
		|  | @ -131,7 +131,35 @@ class Grid: | ||||||
|        Boolean indicating whether *any* column sorting is allowed for |        Boolean indicating whether *any* column sorting is allowed for | ||||||
|        the grid.  Default is ``False``. |        the grid.  Default is ``False``. | ||||||
| 
 | 
 | ||||||
|        See also :attr:`sort_on_backend`. |        See also :attr:`sort_multiple` and :attr:`sort_on_backend`. | ||||||
|  | 
 | ||||||
|  |     .. attribute:: sort_multiple | ||||||
|  | 
 | ||||||
|  |        Boolean indicating whether "multi-column" sorting is allowed. | ||||||
|  |        Default is ``True``; if this is ``False`` then only one column | ||||||
|  |        may be sorted at a time. | ||||||
|  | 
 | ||||||
|  |        Only relevant if :attr:`sortable` is true, but applies to both | ||||||
|  |        frontend and backend sorting. | ||||||
|  | 
 | ||||||
|  |        .. warning:: | ||||||
|  | 
 | ||||||
|  |           This feature is limited by frontend JS capabilities, | ||||||
|  |           regardless of :attr:`sort_on_backend` value (i.e. for both | ||||||
|  |           frontend and backend sorting). | ||||||
|  | 
 | ||||||
|  |           In particular, if the app theme templates use Vue 2 + Buefy, | ||||||
|  |           then multi-column sorting should work. | ||||||
|  | 
 | ||||||
|  |           But not so with Vue 3 + Oruga, *yet* - see also the `open | ||||||
|  |           issue <https://github.com/oruga-ui/oruga/issues/962>`_ | ||||||
|  |           regarding that.  For now this flag is simply ignored for | ||||||
|  |           Vue 3 + Oruga templates. | ||||||
|  | 
 | ||||||
|  |           Additionally, even with Vue 2 + Buefy this flag can only | ||||||
|  |           allow the user to *request* a multi-column sort.  Whereas | ||||||
|  |           the "default sort" in the Vue component can only ever be | ||||||
|  |           single-column, regardless of :attr:`sort_defaults`. | ||||||
| 
 | 
 | ||||||
|     .. attribute:: sort_on_backend |     .. attribute:: sort_on_backend | ||||||
| 
 | 
 | ||||||
|  | @ -150,27 +178,64 @@ class Grid: | ||||||
|        Only relevant if both :attr:`sortable` and |        Only relevant if both :attr:`sortable` and | ||||||
|        :attr:`sort_on_backend` are true. |        :attr:`sort_on_backend` are true. | ||||||
| 
 | 
 | ||||||
|        See also :meth:`set_sorter()`. |        See also :meth:`set_sorter()`, :attr:`sort_defaults` and | ||||||
|  |        :attr:`active_sorters`. | ||||||
| 
 | 
 | ||||||
|     .. attribute:: sort_defaults |     .. attribute:: sort_defaults | ||||||
| 
 | 
 | ||||||
|        List of options to be used for default sorting, until the user |        List of options to be used for default sorting, until the user | ||||||
|        requests a different sorting method. |        requests a different sorting method. | ||||||
| 
 | 
 | ||||||
|        This list usually contains either zero or one elements.  Each |        This list usually contains either zero or one elements.  (More | ||||||
|        element is a :class:`SortInfo` tuple. |        are allowed if :attr:`sort_multiple` is true, but see note | ||||||
|  |        below.)  Each list element is a :class:`SortInfo` tuple and | ||||||
|  |        must correspond to an entry in :attr:`sorters`. | ||||||
| 
 | 
 | ||||||
|        Used with both frontend and backend sorting. |        Used with both frontend and backend sorting. | ||||||
| 
 | 
 | ||||||
|        See also :meth:`set_sort_defaults()`. |        See also :meth:`set_sort_defaults()` and | ||||||
|  |        :attr:`active_sorters`. | ||||||
| 
 | 
 | ||||||
|        .. note:: |        .. warning:: | ||||||
| 
 | 
 | ||||||
|           While the grid logic is meant to handle multi-column |           While the grid logic is built to handle multi-column | ||||||
|           sorting, that is not yet fully implemented. |           sorting, this feature is limited by frontend JS | ||||||
|  |           capabilities. | ||||||
| 
 | 
 | ||||||
|           Therefore only the *first* element from this list is used |           Even if ``sort_defaults`` contains multiple entries | ||||||
|           for the actual default sorting, regardless. |           (i.e. for multi-column sorting to be used "by default" for | ||||||
|  |           the grid), only the *first* entry (i.e. single-column | ||||||
|  |           sorting) will actually be used as the default for the Vue | ||||||
|  |           component. | ||||||
|  | 
 | ||||||
|  |           See also :attr:`sort_multiple` for more details. | ||||||
|  | 
 | ||||||
|  |     .. attribute:: active_sorters | ||||||
|  | 
 | ||||||
|  |        List of sorters currently in effect for the grid; used by | ||||||
|  |        :meth:`sort_data()`. | ||||||
|  | 
 | ||||||
|  |        Whereas :attr:`sorters` defines all "available" sorters, and | ||||||
|  |        :attr:`sort_defaults` defines the "default" sorters, | ||||||
|  |        ``active_sorters`` defines the "current/effective" sorters. | ||||||
|  | 
 | ||||||
|  |        This attribute is set by :meth:`load_settings()`; until that is | ||||||
|  |        called it will not exist. | ||||||
|  | 
 | ||||||
|  |        This is conceptually a "subset" of :attr:`sorters` although a | ||||||
|  |        different format is used here:: | ||||||
|  | 
 | ||||||
|  |           grid.active_sorters = [ | ||||||
|  |               {'key': 'name', 'dir': 'asc'}, | ||||||
|  |               {'key': 'id', 'dir': 'asc'}, | ||||||
|  |           ] | ||||||
|  | 
 | ||||||
|  |        The above is for example only; there is usually no reason to | ||||||
|  |        set this attribute directly. | ||||||
|  | 
 | ||||||
|  |        This list may contain multiple elements only if | ||||||
|  |        :attr:`sort_multiple` is true.  Otherewise it should always | ||||||
|  |        have either zero or one element. | ||||||
| 
 | 
 | ||||||
|     .. attribute:: paginated |     .. attribute:: paginated | ||||||
| 
 | 
 | ||||||
|  | @ -232,6 +297,7 @@ class Grid: | ||||||
|             actions=[], |             actions=[], | ||||||
|             linked_columns=[], |             linked_columns=[], | ||||||
|             sortable=False, |             sortable=False, | ||||||
|  |             sort_multiple=True, | ||||||
|             sort_on_backend=True, |             sort_on_backend=True, | ||||||
|             sorters=None, |             sorters=None, | ||||||
|             sort_defaults=None, |             sort_defaults=None, | ||||||
|  | @ -258,6 +324,10 @@ class Grid: | ||||||
| 
 | 
 | ||||||
|         # sorting |         # sorting | ||||||
|         self.sortable = sortable |         self.sortable = sortable | ||||||
|  |         self.sort_multiple = sort_multiple | ||||||
|  |         if self.sort_multiple and self.request.use_oruga: | ||||||
|  |             log.warning("grid.sort_multiple is not implemented for Oruga-based templates") | ||||||
|  |             self.sort_multiple = False | ||||||
|         self.sort_on_backend = sort_on_backend |         self.sort_on_backend = sort_on_backend | ||||||
|         if sorters is not None: |         if sorters is not None: | ||||||
|             self.sorters = sorters |             self.sorters = sorters | ||||||
|  | @ -265,24 +335,7 @@ class Grid: | ||||||
|             self.sorters = self.make_backend_sorters() |             self.sorters = self.make_backend_sorters() | ||||||
|         else: |         else: | ||||||
|             self.sorters = {} |             self.sorters = {} | ||||||
|         if sort_defaults: |         self.set_sort_defaults(sort_defaults or []) | ||||||
|             if isinstance(sort_defaults, str): |  | ||||||
|                 key = sort_defaults |  | ||||||
|                 sort_defaults = [SortInfo(key, 'asc')] |  | ||||||
|             elif (isinstance(sort_defaults, tuple) |  | ||||||
|                   and len(sort_defaults) == 2 |  | ||||||
|                   and all([isinstance(el, str) for el in sort_defaults])): |  | ||||||
|                 sort_defaults = [SortInfo(*sort_defaults)] |  | ||||||
|             else: |  | ||||||
|                 sort_defaults = [SortInfo(*info) for info in sort_defaults] |  | ||||||
|                 if len(sort_defaults) > 1: |  | ||||||
|                     log.warning("multiple sort defaults are not yet supported; " |  | ||||||
|                                 "list will be pruned to first element for '%s' grid: %s", |  | ||||||
|                                 self.key, sort_defaults) |  | ||||||
|                     sort_defaults = [sort_defaults[0]] |  | ||||||
|             self.sort_defaults = sort_defaults |  | ||||||
|         else: |  | ||||||
|             self.sort_defaults = [] |  | ||||||
| 
 | 
 | ||||||
|         # paging |         # paging | ||||||
|         self.paginated = paginated |         self.paginated = paginated | ||||||
|  | @ -531,7 +584,7 @@ class Grid: | ||||||
| 
 | 
 | ||||||
|         return sorters |         return sorters | ||||||
| 
 | 
 | ||||||
|     def make_sorter(self, columninfo, keyfunc=None, foldcase=False): |     def make_sorter(self, columninfo, keyfunc=None, foldcase=True): | ||||||
|         """ |         """ | ||||||
|         Returns a function suitable for use as a backend sorter on the |         Returns a function suitable for use as a backend sorter on the | ||||||
|         given column. |         given column. | ||||||
|  | @ -549,7 +602,9 @@ class Grid: | ||||||
| 
 | 
 | ||||||
|         :param foldcase: If the sorter is manual (not SQLAlchemy), and |         :param foldcase: If the sorter is manual (not SQLAlchemy), and | ||||||
|            the column data is of text type, this may be used to |            the column data is of text type, this may be used to | ||||||
|            automatically "fold case" for the sorting. |            automatically "fold case" for the sorting.  Defaults to | ||||||
|  |            ``True`` since this behavior is presumably expected, but | ||||||
|  |            may be disabled if needed. | ||||||
| 
 | 
 | ||||||
|         The term "model property" is a bit technical, an example |         The term "model property" is a bit technical, an example | ||||||
|         should help to clarify:: |         should help to clarify:: | ||||||
|  | @ -699,20 +754,68 @@ class Grid: | ||||||
|         """ |         """ | ||||||
|         self.sorters.pop(key, None) |         self.sorters.pop(key, None) | ||||||
| 
 | 
 | ||||||
|     def set_sort_defaults(self, sortkey, sortdir='asc'): |     def set_sort_defaults(self, *args): | ||||||
|         """ |         """ | ||||||
|         Set the default sorting method for the grid. |         Set the default sorting method for the grid.  This sorting is | ||||||
|  |         used unless/until the user requests a different sorting | ||||||
|  |         method. | ||||||
| 
 | 
 | ||||||
|         This sorting is used unless/until the user requests a |         ``args`` for this method are interpreted as follows: | ||||||
|         different sorting method. |  | ||||||
| 
 | 
 | ||||||
|         :param sortkey: Name of the column by which to sort. |         If 2 args are received, they should be for ``sortkey`` and | ||||||
|  |         ``sortdir``; for instance:: | ||||||
| 
 | 
 | ||||||
|         :param sortdir: Must be either ``'asc'`` or ``'desc'``. |            grid.set_sort_defaults('name', 'asc') | ||||||
|  | 
 | ||||||
|  |         If just one 2-tuple arg is received, it is handled similarly:: | ||||||
|  | 
 | ||||||
|  |            grid.set_sort_defaults(('name', 'asc')) | ||||||
|  | 
 | ||||||
|  |         If just one string arg is received, the default ``sortdir`` is | ||||||
|  |         assumed:: | ||||||
|  | 
 | ||||||
|  |            grid.set_sort_defaults('name') # assumes 'asc' | ||||||
|  | 
 | ||||||
|  |         Otherwise there should be just one list arg, elements of | ||||||
|  |         which are each 2-tuples of ``(sortkey, sortdir)`` info:: | ||||||
|  | 
 | ||||||
|  |            grid.set_sort_defaults([('name', 'asc'), | ||||||
|  |                                    ('value', 'desc')]) | ||||||
|  | 
 | ||||||
|  |         .. note:: | ||||||
|  | 
 | ||||||
|  |            Note that :attr:`sort_multiple` determines whether the grid | ||||||
|  |            is actually allowed to have multiple sort defaults.  The | ||||||
|  |            defaults requested by the method call may be pruned if | ||||||
|  |            necessary to accommodate that. | ||||||
| 
 | 
 | ||||||
|         Default sorting info is tracked via :attr:`sort_defaults`. |         Default sorting info is tracked via :attr:`sort_defaults`. | ||||||
|         """ |         """ | ||||||
|         self.sort_defaults = [SortInfo(sortkey, sortdir)] | 
 | ||||||
|  |         # convert args to sort defaults | ||||||
|  |         sort_defaults = [] | ||||||
|  |         if len(args) == 1: | ||||||
|  |             if isinstance(args[0], str): | ||||||
|  |                 sort_defaults = [SortInfo(args[0], 'asc')] | ||||||
|  |             elif isinstance(args[0], tuple) and len(args[0]) == 2: | ||||||
|  |                 sort_defaults = [SortInfo(*args[0])] | ||||||
|  |             elif isinstance(args[0], list): | ||||||
|  |                 sort_defaults = [SortInfo(*tup) for tup in args[0]] | ||||||
|  |             else: | ||||||
|  |                 raise ValueError("for just one positional arg, must pass string, 2-tuple or list") | ||||||
|  |         elif len(args) == 2: | ||||||
|  |             sort_defaults = [SortInfo(*args)] | ||||||
|  |         else: | ||||||
|  |             raise ValueError("must pass just one or two positional args") | ||||||
|  | 
 | ||||||
|  |         # prune if multi-column requested but not supported | ||||||
|  |         if len(sort_defaults) > 1 and not self.sort_multiple: | ||||||
|  |             log.warning("multi-column sorting is not enabled for the instance; " | ||||||
|  |                         "list will be pruned to first element for '%s' grid: %s", | ||||||
|  |                         self.key, sort_defaults) | ||||||
|  |             sort_defaults = [sort_defaults[0]] | ||||||
|  | 
 | ||||||
|  |         self.sort_defaults = sort_defaults | ||||||
| 
 | 
 | ||||||
|     def is_sortable(self, key): |     def is_sortable(self, key): | ||||||
|         """ |         """ | ||||||
|  | @ -795,7 +898,7 @@ class Grid: | ||||||
|     # configuration methods |     # configuration methods | ||||||
|     ############################## |     ############################## | ||||||
| 
 | 
 | ||||||
|     def load_settings(self, store=True): |     def load_settings(self, persist=True): | ||||||
|         """ |         """ | ||||||
|         Load all effective settings for the grid. |         Load all effective settings for the grid. | ||||||
| 
 | 
 | ||||||
|  | @ -821,7 +924,7 @@ class Grid: | ||||||
|         are saved each time they are loaded.  Note that such settings |         are saved each time they are loaded.  Note that such settings | ||||||
|         are wiped upon user logout. |         are wiped upon user logout. | ||||||
| 
 | 
 | ||||||
|         :param store: Whether the collected settings should be saved |         :param persist: Whether the collected settings should be saved | ||||||
|            to the user session. |            to the user session. | ||||||
|         """ |         """ | ||||||
| 
 | 
 | ||||||
|  | @ -829,13 +932,9 @@ class Grid: | ||||||
|         settings = {} |         settings = {} | ||||||
|         if self.sortable: |         if self.sortable: | ||||||
|             if self.sort_defaults: |             if self.sort_defaults: | ||||||
|                 sort_defaults = self.sort_defaults |                 # nb. as of writing neither Buefy nor Oruga support a | ||||||
|                 if len(sort_defaults) > 1: |                 # multi-column *default* sort; so just use first sorter | ||||||
|                     log.warning("multiple sort defaults are not yet supported; " |                 sortinfo = self.sort_defaults[0] | ||||||
|                                 "list will be pruned to first element for '%s' grid: %s", |  | ||||||
|                                 self.key, sort_defaults) |  | ||||||
|                     sort_defaults = [sort_defaults[0]] |  | ||||||
|                 sortinfo = sort_defaults[0] |  | ||||||
|                 settings['sorters.length'] = 1 |                 settings['sorters.length'] = 1 | ||||||
|                 settings['sorters.1.key'] = sortinfo.sortkey |                 settings['sorters.1.key'] = sortinfo.sortkey | ||||||
|                 settings['sorters.1.dir'] = sortinfo.sortdir |                 settings['sorters.1.dir'] = sortinfo.sortdir | ||||||
|  | @ -858,25 +957,35 @@ class Grid: | ||||||
| 
 | 
 | ||||||
|         else: |         else: | ||||||
|             # nothing found in request, so nothing new to save |             # nothing found in request, so nothing new to save | ||||||
|             store = False |             persist = False | ||||||
| 
 | 
 | ||||||
|             # but still should load whatever is in user session |             # but still should load whatever is in user session | ||||||
|             self.update_sort_settings(settings, src='session') |             self.update_sort_settings(settings, src='session') | ||||||
|             self.update_page_settings(settings) |             self.update_page_settings(settings) | ||||||
| 
 | 
 | ||||||
|         # maybe store settings in user session, for next time |         # maybe save settings in user session, for next time | ||||||
|         if store: |         if persist: | ||||||
|             self.persist_settings(settings, dest='session') |             self.persist_settings(settings, dest='session') | ||||||
| 
 | 
 | ||||||
|         # update ourself to reflect settings |         # update ourself to reflect settings dict.. | ||||||
|  | 
 | ||||||
|  |         # sorting | ||||||
|         if self.sortable: |         if self.sortable: | ||||||
|             # and self.sort_on_backend: |             # nb. doing this for frontend sorting also | ||||||
|             self.active_sorters = [] |             self.active_sorters = [] | ||||||
|             for i in range(1, settings['sorters.length'] + 1): |             for i in range(1, settings['sorters.length'] + 1): | ||||||
|                 self.active_sorters.append({ |                 self.active_sorters.append({ | ||||||
|                     'key': settings[f'sorters.{i}.key'], |                     'key': settings[f'sorters.{i}.key'], | ||||||
|                     'dir': settings[f'sorters.{i}.dir'], |                     'dir': settings[f'sorters.{i}.dir'], | ||||||
|                 }) |                 }) | ||||||
|  |                 # TODO: i thought this was needed, but now idk? | ||||||
|  |                 # # nb. when showing full index page (i.e. not partial) | ||||||
|  |                 # # this implies we must set the default sorter for Vue | ||||||
|  |                 # # component, and only single-column is allowed there. | ||||||
|  |                 # if not self.request.GET.get('partial'): | ||||||
|  |                 #     break | ||||||
|  | 
 | ||||||
|  |         # paging | ||||||
|         if self.paginated and self.paginate_on_backend: |         if self.paginated and self.paginate_on_backend: | ||||||
|             self.pagesize = settings['pagesize'] |             self.pagesize = settings['pagesize'] | ||||||
|             self.page = settings['page'] |             self.page = settings['page'] | ||||||
|  | @ -1046,30 +1155,38 @@ class Grid: | ||||||
| 
 | 
 | ||||||
|     def sort_data(self, data, sorters=None): |     def sort_data(self, data, sorters=None): | ||||||
|         """ |         """ | ||||||
|         Sort the given query according to current settings, and return the result. |         Sort the given data and return the result.  This is called by | ||||||
|  |         :meth:`get_visible_data()`. | ||||||
| 
 | 
 | ||||||
|         Optional list of sorters to use.  If not specified, the grid's |         :param sorters: Optional list of sorters to use.  If not | ||||||
|         "active" sorter list is used. |            specified, the grid's :attr:`active_sorters` are used. | ||||||
|         """ |         """ | ||||||
|         if sorters is None: |         if sorters is None: | ||||||
|             sorters = self.active_sorters |             sorters = self.active_sorters | ||||||
|         if not sorters: |         if not sorters: | ||||||
|             return data |             return data | ||||||
|         if len(sorters) != 1: |  | ||||||
|             raise NotImplementedError("mulit-column sorting not yet supported") |  | ||||||
| 
 | 
 | ||||||
|         # our one and only active sorter |         # nb. when data is a query, we want to apply sorters in the | ||||||
|         sorter = sorters[0] |         # requested order, so the final query has order_by() in the | ||||||
|         sortkey = sorter['key'] |         # correct "as-is" sequence.  however when data is a list we | ||||||
|         sortdir = sorter['dir'] |         # must do the opposite, applying in the reverse order, so the | ||||||
|  |         # final list has the most "important" sort(s) applied last. | ||||||
|  |         if not isinstance(data, orm.Query): | ||||||
|  |             sorters = reversed(sorters) | ||||||
| 
 | 
 | ||||||
|         # cannot sort unless we have a sorter callable |         for sorter in sorters: | ||||||
|         sortfunc = self.sorters.get(sortkey) |             sortkey = sorter['key'] | ||||||
|         if not sortfunc: |             sortdir = sorter['dir'] | ||||||
|             return data |  | ||||||
| 
 | 
 | ||||||
|         # invoke the sorter |             # cannot sort unless we have a sorter callable | ||||||
|         return sortfunc(data, sortdir) |             sortfunc = self.sorters.get(sortkey) | ||||||
|  |             if not sortfunc: | ||||||
|  |                 return data | ||||||
|  | 
 | ||||||
|  |             # invoke the sorter | ||||||
|  |             data = sortfunc(data, sortdir) | ||||||
|  | 
 | ||||||
|  |         return data | ||||||
| 
 | 
 | ||||||
|     def paginate_data(self, data): |     def paginate_data(self, data): | ||||||
|         """ |         """ | ||||||
|  | @ -1182,6 +1299,29 @@ class Grid: | ||||||
|             }) |             }) | ||||||
|         return columns |         return columns | ||||||
| 
 | 
 | ||||||
|  |     def get_vue_active_sorters(self): | ||||||
|  |         """ | ||||||
|  |         Returns a list of Vue-compatible column sorter definitions. | ||||||
|  | 
 | ||||||
|  |         The list returned is the same as :attr:`active_sorters`; | ||||||
|  |         however the format used in Vue is different.  So this method | ||||||
|  |         just "converts" them to the required format, e.g.:: | ||||||
|  | 
 | ||||||
|  |            # active_sorters format | ||||||
|  |            {'key': 'name', 'dir': 'asc'} | ||||||
|  | 
 | ||||||
|  |            # get_vue_active_sorters() format | ||||||
|  |            {'field': 'name', 'order': 'asc'} | ||||||
|  | 
 | ||||||
|  |         :returns: The :attr:`active_sorters` list, converted as | ||||||
|  |            described above. | ||||||
|  |         """ | ||||||
|  |         sorters = [] | ||||||
|  |         for sorter in self.active_sorters: | ||||||
|  |             sorters.append({'field': sorter['key'], | ||||||
|  |                             'order': sorter['dir']}) | ||||||
|  |         return sorters | ||||||
|  | 
 | ||||||
|     def get_vue_data(self): |     def get_vue_data(self): | ||||||
|         """ |         """ | ||||||
|         Returns a list of Vue-compatible data records. |         Returns a list of Vue-compatible data records. | ||||||
|  |  | ||||||
|  | @ -9,12 +9,29 @@ | ||||||
| 
 | 
 | ||||||
|               ## sorting |               ## sorting | ||||||
|               % if grid.sortable: |               % if grid.sortable: | ||||||
|                   ## nb. buefy only supports *one* default sorter |                   ## nb. buefy/oruga only support *one* default sorter | ||||||
|                   :default-sort="sorters.length ? [sorters[0].key, sorters[0].dir] : null" |                   :default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null" | ||||||
|                   % if grid.sort_on_backend: |                   % if grid.sort_on_backend: | ||||||
|                       backend-sorting |                       backend-sorting | ||||||
|                       @sort="onSort" |                       @sort="onSort" | ||||||
|                   % endif |                   % endif | ||||||
|  |                   % if grid.sort_multiple: | ||||||
|  |                       % if grid.sort_on_backend: | ||||||
|  |                           ## TODO: there is a bug (?) which prevents the arrow | ||||||
|  |                           ## from displaying for simple default single-column sort, | ||||||
|  |                           ## when multi-column sort is allowed for the table.  for | ||||||
|  |                           ## now we work around that by waiting until mount to | ||||||
|  |                           ## enable the multi-column support.  see also | ||||||
|  |                           ## https://github.com/buefy/buefy/issues/2584 | ||||||
|  |                           :sort-multiple="allowMultiSort" | ||||||
|  |                           :sort-multiple-data="sortingPriority" | ||||||
|  |                           @sorting-priority-removed="sortingPriorityRemoved" | ||||||
|  |                       % else: | ||||||
|  |                           sort-multiple | ||||||
|  |                       % endif | ||||||
|  |                       ## nb. user must ctrl-click column header for multi-sort | ||||||
|  |                       sort-multiple-key="ctrlKey" | ||||||
|  |                   % endif | ||||||
|               % endif |               % endif | ||||||
| 
 | 
 | ||||||
|               ## paging |               ## paging | ||||||
|  | @ -119,7 +136,24 @@ | ||||||
| 
 | 
 | ||||||
|       ## sorting |       ## sorting | ||||||
|       % if grid.sortable: |       % if grid.sortable: | ||||||
|           sorters: ${json.dumps(grid.active_sorters)|n}, |           sorters: ${json.dumps(grid.get_vue_active_sorters())|n}, | ||||||
|  |           % if grid.sort_multiple: | ||||||
|  |               % if grid.sort_on_backend: | ||||||
|  |                   ## TODO: there is a bug (?) which prevents the arrow | ||||||
|  |                   ## from displaying for simple default single-column sort, | ||||||
|  |                   ## when multi-column sort is allowed for the table.  for | ||||||
|  |                   ## now we work around that by waiting until mount to | ||||||
|  |                   ## enable the multi-column support.  see also | ||||||
|  |                   ## https://github.com/buefy/buefy/issues/2584 | ||||||
|  |                   allowMultiSort: false, | ||||||
|  |                   ## nb. this should be empty when current sort is single-column | ||||||
|  |                   % if len(grid.active_sorters) > 1: | ||||||
|  |                       sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n}, | ||||||
|  |                   % else: | ||||||
|  |                       sortingPriority: [], | ||||||
|  |                   % endif | ||||||
|  |               % endif | ||||||
|  |           % endif | ||||||
|       % endif |       % endif | ||||||
| 
 | 
 | ||||||
|       ## paging |       ## paging | ||||||
|  | @ -157,6 +191,21 @@ | ||||||
| 
 | 
 | ||||||
|           % endif |           % endif | ||||||
|       }, |       }, | ||||||
|  | 
 | ||||||
|  |       % if grid.sortable and grid.sort_multiple and grid.sort_on_backend: | ||||||
|  | 
 | ||||||
|  |             ## TODO: there is a bug (?) which prevents the arrow | ||||||
|  |             ## from displaying for simple default single-column sort, | ||||||
|  |             ## when multi-column sort is allowed for the table.  for | ||||||
|  |             ## now we work around that by waiting until mount to | ||||||
|  |             ## enable the multi-column support.  see also | ||||||
|  |             ## https://github.com/buefy/buefy/issues/2584 | ||||||
|  |             mounted() { | ||||||
|  |                 this.allowMultiSort = true | ||||||
|  |             }, | ||||||
|  | 
 | ||||||
|  |       % endif | ||||||
|  | 
 | ||||||
|       methods: { |       methods: { | ||||||
| 
 | 
 | ||||||
|           renderNumber(value) { |           renderNumber(value) { | ||||||
|  | @ -174,8 +223,8 @@ | ||||||
|               } |               } | ||||||
|               % if grid.sortable and grid.sort_on_backend: |               % if grid.sortable and grid.sort_on_backend: | ||||||
|                   for (let i = 1; i <= this.sorters.length; i++) { |                   for (let i = 1; i <= this.sorters.length; i++) { | ||||||
|                       params['sort'+i+'key'] = this.sorters[i-1].key |                       params['sort'+i+'key'] = this.sorters[i-1].field | ||||||
|                       params['sort'+i+'dir'] = this.sorters[i-1].dir |                       params['sort'+i+'dir'] = this.sorters[i-1].order | ||||||
|                   } |                   } | ||||||
|               % endif |               % endif | ||||||
|               return params |               return params | ||||||
|  | @ -226,14 +275,56 @@ | ||||||
|                       field = field.field |                       field = field.field | ||||||
|                   % endif |                   % endif | ||||||
| 
 | 
 | ||||||
|  |                   % if grid.sort_multiple: | ||||||
|  | 
 | ||||||
|  |                       // did user ctrl-click the column header? | ||||||
|  |                       if (event.ctrlKey) { | ||||||
|  | 
 | ||||||
|  |                           // toggle direction for existing, or add new sorter | ||||||
|  |                           const sorter = this.sorters.filter(s => s.field === field)[0] | ||||||
|  |                           if (sorter) { | ||||||
|  |                               sorter.order = sorter.order === 'desc' ? 'asc' : 'desc' | ||||||
|  |                           } else { | ||||||
|  |                               this.sorters.push({field, order}) | ||||||
|  |                           } | ||||||
|  | 
 | ||||||
|  |                           // apply multi-column sorting | ||||||
|  |                           this.sortingPriority = this.sorters | ||||||
|  | 
 | ||||||
|  |                       } else { | ||||||
|  | 
 | ||||||
|  |                   % endif | ||||||
|  | 
 | ||||||
|                   // sort by single column only |                   // sort by single column only | ||||||
|                   this.sorters = [{key: field, dir: order}] |                   this.sorters = [{field, order}] | ||||||
|  | 
 | ||||||
|  |                   % if grid.sort_multiple: | ||||||
|  |                           // multi-column sort not engaged | ||||||
|  |                           this.sortingPriority = [] | ||||||
|  |                       } | ||||||
|  |                   % endif | ||||||
| 
 | 
 | ||||||
|                   // nb. always reset to first page when sorting changes |                   // nb. always reset to first page when sorting changes | ||||||
|                   this.currentPage = 1 |                   this.currentPage = 1 | ||||||
|                   this.fetchData() |                   this.fetchData() | ||||||
|               }, |               }, | ||||||
| 
 | 
 | ||||||
|  |               % if grid.sort_multiple: | ||||||
|  | 
 | ||||||
|  |                   sortingPriorityRemoved(field) { | ||||||
|  | 
 | ||||||
|  |                       // prune from active sorters | ||||||
|  |                       this.sorters = this.sorters.filter(s => s.field !== field) | ||||||
|  | 
 | ||||||
|  |                       // nb. even though we might have just one sorter | ||||||
|  |                       // now, we are still technically in multi-sort mode | ||||||
|  |                       this.sortingPriority = this.sorters | ||||||
|  | 
 | ||||||
|  |                       this.fetchData() | ||||||
|  |                   }, | ||||||
|  | 
 | ||||||
|  |               % endif | ||||||
|  | 
 | ||||||
|           % endif |           % endif | ||||||
| 
 | 
 | ||||||
|           % if grid.paginated: |           % if grid.paginated: | ||||||
|  |  | ||||||
|  | @ -1124,6 +1124,7 @@ class MasterView(View): | ||||||
|             kwargs['actions'] = actions |             kwargs['actions'] = actions | ||||||
| 
 | 
 | ||||||
|         kwargs.setdefault('sortable', self.sortable) |         kwargs.setdefault('sortable', self.sortable) | ||||||
|  |         kwargs.setdefault('sort_multiple', not self.request.use_oruga) | ||||||
|         kwargs.setdefault('sort_on_backend', self.sort_on_backend) |         kwargs.setdefault('sort_on_backend', self.sort_on_backend) | ||||||
|         kwargs.setdefault('sort_defaults', self.sort_defaults) |         kwargs.setdefault('sort_defaults', self.sort_defaults) | ||||||
|         kwargs.setdefault('paginated', self.paginated) |         kwargs.setdefault('paginated', self.paginated) | ||||||
|  |  | ||||||
|  | @ -71,10 +71,19 @@ class TestGrid(WebTestCase): | ||||||
|                               sort_defaults=[('name', 'desc')]) |                               sort_defaults=[('name', 'desc')]) | ||||||
|         self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) |         self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) | ||||||
| 
 | 
 | ||||||
|         # sort defaults as list w/ multiple |         # multi-column defaults | ||||||
|         grid = self.make_grid(model_class=model.Setting, sortable=True, |         grid = self.make_grid(model_class=model.Setting, sortable=True, | ||||||
|  |                               sort_multiple=True, | ||||||
|                               sort_defaults=[('name', 'desc'), ('value', 'asc')]) |                               sort_defaults=[('name', 'desc'), ('value', 'asc')]) | ||||||
|         self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) |         self.assertTrue(grid.sort_multiple) | ||||||
|  |         self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc'), | ||||||
|  |                                               mod.SortInfo('value', 'asc')]) | ||||||
|  | 
 | ||||||
|  |         # multi-column sort disabled for oruga | ||||||
|  |         self.request.use_oruga = True | ||||||
|  |         grid = self.make_grid(model_class=model.Setting, sortable=True, | ||||||
|  |                               sort_multiple=True) | ||||||
|  |         self.assertFalse(grid.sort_multiple) | ||||||
| 
 | 
 | ||||||
|     def test_vue_tagname(self): |     def test_vue_tagname(self): | ||||||
|         grid = self.make_grid() |         grid = self.make_grid() | ||||||
|  | @ -236,7 +245,7 @@ class TestGrid(WebTestCase): | ||||||
| 
 | 
 | ||||||
|         # can skip the saving step |         # can skip the saving step | ||||||
|         self.request.GET = {'pagesize': '10', 'page': '3'} |         self.request.GET = {'pagesize': '10', 'page': '3'} | ||||||
|         grid.load_settings(store=False) |         grid.load_settings(persist=False) | ||||||
|         self.assertEqual(grid.page, 3) |         self.assertEqual(grid.page, 3) | ||||||
|         self.assertEqual(self.request.session['grid.foo.page'], 2) |         self.assertEqual(self.request.session['grid.foo.page'], 2) | ||||||
| 
 | 
 | ||||||
|  | @ -261,7 +270,7 @@ class TestGrid(WebTestCase): | ||||||
| 
 | 
 | ||||||
|         # can skip the saving step |         # can skip the saving step | ||||||
|         self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'} |         self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'} | ||||||
|         grid.load_settings(store=False) |         grid.load_settings(persist=False) | ||||||
|         self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) |         self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) | ||||||
|         self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) |         self.assertEqual(self.request.session['grid.settings.sorters.length'], 1) | ||||||
|         self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') |         self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name') | ||||||
|  | @ -575,21 +584,21 @@ class TestGrid(WebTestCase): | ||||||
|         sorted_data = sorter(sample_data, 'asc') |         sorted_data = sorter(sample_data, 'asc') | ||||||
|         self.assertEqual(len(sorted_data), 9) |         self.assertEqual(len(sorted_data), 9) | ||||||
| 
 | 
 | ||||||
|         # case folding is off by default |         # case folding is on by default | ||||||
|         grid = self.make_grid(model_class=model.Setting) |         grid = self.make_grid(model_class=model.Setting) | ||||||
|         sorter = grid.make_sorter('value') |         sorter = grid.make_sorter('value') | ||||||
|         sorted_data = sorter(sample_data, 'desc') |         sorted_data = sorter(sample_data, 'desc') | ||||||
|         self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'}) |         self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'}) | ||||||
|         sorted_data = sorter(sample_data, 'asc') |         sorted_data = sorter(sample_data, 'asc') | ||||||
|         self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'}) |         self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) | ||||||
| 
 | 
 | ||||||
|         # results are different with case folding |         # results are different with case folding off | ||||||
|         grid = self.make_grid(model_class=model.Setting) |         grid = self.make_grid(model_class=model.Setting) | ||||||
|         sorter = grid.make_sorter('value', foldcase=True) |         sorter = grid.make_sorter('value', foldcase=False) | ||||||
|         sorted_data = sorter(sample_data, 'desc') |         sorted_data = sorter(sample_data, 'desc') | ||||||
|         self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'}) |         self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'}) | ||||||
|         sorted_data = sorter(sample_data, 'asc') |         sorted_data = sorter(sample_data, 'asc') | ||||||
|         self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) |         self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'}) | ||||||
| 
 | 
 | ||||||
|     def test_set_sorter(self): |     def test_set_sorter(self): | ||||||
|         model = self.app.model |         model = self.app.model | ||||||
|  | @ -632,13 +641,38 @@ class TestGrid(WebTestCase): | ||||||
| 
 | 
 | ||||||
|     def test_set_sort_defaults(self): |     def test_set_sort_defaults(self): | ||||||
|         model = self.app.model |         model = self.app.model | ||||||
| 
 |  | ||||||
|         # basics |  | ||||||
|         grid = self.make_grid(model_class=model.Setting, sortable=True) |         grid = self.make_grid(model_class=model.Setting, sortable=True) | ||||||
|         self.assertEqual(grid.sort_defaults, []) |         self.assertEqual(grid.sort_defaults, []) | ||||||
|         grid.set_sort_defaults('name', 'asc') | 
 | ||||||
|  |         # can set just sortkey | ||||||
|  |         grid.set_sort_defaults('name') | ||||||
|         self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')]) |         self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')]) | ||||||
| 
 | 
 | ||||||
|  |         # can set sortkey, sortdir | ||||||
|  |         grid.set_sort_defaults('name', 'desc') | ||||||
|  |         self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')]) | ||||||
|  | 
 | ||||||
|  |         # can set sortkey, sortdir as tuple | ||||||
|  |         grid.set_sort_defaults(('value', 'asc')) | ||||||
|  |         self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')]) | ||||||
|  | 
 | ||||||
|  |         # can set as list | ||||||
|  |         grid.sort_multiple = True | ||||||
|  |         grid.set_sort_defaults([('value', 'asc'), ('name', 'desc')]) | ||||||
|  |         self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc'), | ||||||
|  |                                               mod.SortInfo('name', 'desc')]) | ||||||
|  | 
 | ||||||
|  |         # list is pruned if multi-sort disabled | ||||||
|  |         grid.sort_multiple = False | ||||||
|  |         grid.set_sort_defaults([('value', 'asc'), ('name', 'desc')]) | ||||||
|  |         self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')]) | ||||||
|  | 
 | ||||||
|  |         # error if any other single arg | ||||||
|  |         self.assertRaises(ValueError, grid.set_sort_defaults, 42) | ||||||
|  | 
 | ||||||
|  |         # error if more than 2 args | ||||||
|  |         self.assertRaises(ValueError, grid.set_sort_defaults, 'name', 'asc', 'value', 'desc') | ||||||
|  | 
 | ||||||
|     def test_is_sortable(self): |     def test_is_sortable(self): | ||||||
|         model = self.app.model |         model = self.app.model | ||||||
| 
 | 
 | ||||||
|  | @ -701,9 +735,9 @@ class TestGrid(WebTestCase): | ||||||
|         sample_data = [ |         sample_data = [ | ||||||
|             {'name': 'foo1', 'value': 'ONE'}, |             {'name': 'foo1', 'value': 'ONE'}, | ||||||
|             {'name': 'foo2', 'value': 'two'}, |             {'name': 'foo2', 'value': 'two'}, | ||||||
|             {'name': 'foo3', 'value': 'three'}, |             {'name': 'foo3', 'value': 'ggg'}, | ||||||
|             {'name': 'foo4', 'value': 'four'}, |             {'name': 'foo4', 'value': 'ggg'}, | ||||||
|             {'name': 'foo5', 'value': 'five'}, |             {'name': 'foo5', 'value': 'ggg'}, | ||||||
|             {'name': 'foo6', 'value': 'six'}, |             {'name': 'foo6', 'value': 'six'}, | ||||||
|             {'name': 'foo7', 'value': 'seven'}, |             {'name': 'foo7', 'value': 'seven'}, | ||||||
|             {'name': 'foo8', 'value': 'eight'}, |             {'name': 'foo8', 'value': 'eight'}, | ||||||
|  | @ -740,15 +774,26 @@ class TestGrid(WebTestCase): | ||||||
|         self.assertEqual(sorted_data[0]['name'], 'foo1') |         self.assertEqual(sorted_data[0]['name'], 'foo1') | ||||||
|         self.assertEqual(sorted_data[-1]['name'], 'foo9') |         self.assertEqual(sorted_data[-1]['name'], 'foo9') | ||||||
| 
 | 
 | ||||||
|         # error if mult-column sort attempted |         # multi-column sorting for list data | ||||||
|         self.assertRaises(NotImplementedError, grid.sort_data, sample_data, sorters=[ |         sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'}, | ||||||
|             {'key': 'name', 'dir': 'desc'}, |                                                            {'key': 'name', 'dir': 'asc'}]) | ||||||
|             {'key': 'value', 'dir': 'asc'}, |         self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) | ||||||
|         ]) |         self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'}) | ||||||
|  |         self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'}) | ||||||
|  |         self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'}) | ||||||
|  | 
 | ||||||
|  |         # multi-column sorting for query | ||||||
|  |         sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'}, | ||||||
|  |                                                              {'key': 'name', 'dir': 'asc'}]) | ||||||
|  |         self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'}) | ||||||
|  |         self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'}) | ||||||
|  |         self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'}) | ||||||
|  |         self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'}) | ||||||
| 
 | 
 | ||||||
|         # cannot sort data if sortfunc is missing for column |         # cannot sort data if sortfunc is missing for column | ||||||
|         grid.remove_sorter('name') |         grid.remove_sorter('name') | ||||||
|         sorted_data = grid.sort_data(sample_data) |         sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'}, | ||||||
|  |                                                            {'key': 'name', 'dir': 'asc'}]) | ||||||
|         # nb. sorted data is in same order as original sample (not sorted) |         # nb. sorted data is in same order as original sample (not sorted) | ||||||
|         self.assertEqual(sorted_data[0]['name'], 'foo1') |         self.assertEqual(sorted_data[0]['name'], 'foo1') | ||||||
|         self.assertEqual(sorted_data[-1]['name'], 'foo9') |         self.assertEqual(sorted_data[-1]['name'], 'foo9') | ||||||
|  | @ -823,6 +868,24 @@ class TestGrid(WebTestCase): | ||||||
|         self.assertEqual(first['field'], 'foo') |         self.assertEqual(first['field'], 'foo') | ||||||
|         self.assertEqual(first['label'], 'Foo') |         self.assertEqual(first['label'], 'Foo') | ||||||
| 
 | 
 | ||||||
|  |     def test_get_vue_active_sorters(self): | ||||||
|  |         model = self.app.model | ||||||
|  | 
 | ||||||
|  |         # empty | ||||||
|  |         grid = self.make_grid(key='foo', sortable=True, sort_on_backend=True) | ||||||
|  |         grid.load_settings() | ||||||
|  |         sorters = grid.get_vue_active_sorters() | ||||||
|  |         self.assertEqual(sorters, []) | ||||||
|  | 
 | ||||||
|  |         # format is different | ||||||
|  |         grid = self.make_grid(key='settings', model_class=model.Setting, | ||||||
|  |                               sortable=True, sort_on_backend=True, | ||||||
|  |                               sort_defaults='name') | ||||||
|  |         grid.load_settings() | ||||||
|  |         self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}]) | ||||||
|  |         sorters = grid.get_vue_active_sorters() | ||||||
|  |         self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}]) | ||||||
|  | 
 | ||||||
|     def test_get_vue_data(self): |     def test_get_vue_data(self): | ||||||
| 
 | 
 | ||||||
|         # empty if no columns defined |         # empty if no columns defined | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Lance Edgar
						Lance Edgar