1
0
Fork 0

feat: add multi-column sorting (frontend or backend) for grids

This commit is contained in:
Lance Edgar 2024-08-18 19:23:15 -05:00
parent 58f7a862a2
commit 8d6f4ad368
4 changed files with 388 additions and 93 deletions

View file

@ -131,7 +131,35 @@ class Grid:
Boolean indicating whether *any* column sorting is allowed for
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
@ -150,27 +178,64 @@ class Grid:
Only relevant if both :attr:`sortable` and
: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
List of options to be used for default sorting, until the user
requests a different sorting method.
This list usually contains either zero or one elements. Each
element is a :class:`SortInfo` tuple.
This list usually contains either zero or one elements. (More
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.
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
sorting, that is not yet fully implemented.
While the grid logic is built to handle multi-column
sorting, this feature is limited by frontend JS
capabilities.
Therefore only the *first* element from this list is used
for the actual default sorting, regardless.
Even if ``sort_defaults`` contains multiple entries
(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
@ -232,6 +297,7 @@ class Grid:
actions=[],
linked_columns=[],
sortable=False,
sort_multiple=True,
sort_on_backend=True,
sorters=None,
sort_defaults=None,
@ -258,6 +324,10 @@ class Grid:
# sorting
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
if sorters is not None:
self.sorters = sorters
@ -265,24 +335,7 @@ class Grid:
self.sorters = self.make_backend_sorters()
else:
self.sorters = {}
if sort_defaults:
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 = []
self.set_sort_defaults(sort_defaults or [])
# paging
self.paginated = paginated
@ -531,7 +584,7 @@ class Grid:
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
given column.
@ -549,7 +602,9 @@ class Grid:
:param foldcase: If the sorter is manual (not SQLAlchemy), and
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
should help to clarify::
@ -699,20 +754,68 @@ class Grid:
"""
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
different sorting method.
``args`` for this method are interpreted as follows:
: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`.
"""
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):
"""
@ -795,7 +898,7 @@ class Grid:
# configuration methods
##############################
def load_settings(self, store=True):
def load_settings(self, persist=True):
"""
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 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.
"""
@ -829,13 +932,9 @@ class Grid:
settings = {}
if self.sortable:
if self.sort_defaults:
sort_defaults = self.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]]
sortinfo = sort_defaults[0]
# nb. as of writing neither Buefy nor Oruga support a
# multi-column *default* sort; so just use first sorter
sortinfo = self.sort_defaults[0]
settings['sorters.length'] = 1
settings['sorters.1.key'] = sortinfo.sortkey
settings['sorters.1.dir'] = sortinfo.sortdir
@ -858,25 +957,35 @@ class Grid:
else:
# nothing found in request, so nothing new to save
store = False
persist = False
# but still should load whatever is in user session
self.update_sort_settings(settings, src='session')
self.update_page_settings(settings)
# maybe store settings in user session, for next time
if store:
# maybe save settings in user session, for next time
if persist:
self.persist_settings(settings, dest='session')
# update ourself to reflect settings
# update ourself to reflect settings dict..
# sorting
if self.sortable:
# and self.sort_on_backend:
# nb. doing this for frontend sorting also
self.active_sorters = []
for i in range(1, settings['sorters.length'] + 1):
self.active_sorters.append({
'key': settings[f'sorters.{i}.key'],
'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:
self.pagesize = settings['pagesize']
self.page = settings['page']
@ -1046,20 +1155,26 @@ class Grid:
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
"active" sorter list is used.
:param sorters: Optional list of sorters to use. If not
specified, the grid's :attr:`active_sorters` are used.
"""
if sorters is None:
sorters = self.active_sorters
if not sorters:
return data
if len(sorters) != 1:
raise NotImplementedError("mulit-column sorting not yet supported")
# our one and only active sorter
sorter = sorters[0]
# nb. when data is a query, we want to apply sorters in the
# requested order, so the final query has order_by() in the
# correct "as-is" sequence. however when data is a list we
# 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)
for sorter in sorters:
sortkey = sorter['key']
sortdir = sorter['dir']
@ -1069,7 +1184,9 @@ class Grid:
return data
# invoke the sorter
return sortfunc(data, sortdir)
data = sortfunc(data, sortdir)
return data
def paginate_data(self, data):
"""
@ -1182,6 +1299,29 @@ class Grid:
})
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):
"""
Returns a list of Vue-compatible data records.

View file

@ -9,12 +9,29 @@
## sorting
% if grid.sortable:
## nb. buefy only supports *one* default sorter
:default-sort="sorters.length ? [sorters[0].key, sorters[0].dir] : null"
## nb. buefy/oruga only support *one* default sorter
:default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null"
% if grid.sort_on_backend:
backend-sorting
@sort="onSort"
% 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
## paging
@ -119,7 +136,24 @@
## sorting
% 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
## paging
@ -157,6 +191,21 @@
% 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: {
renderNumber(value) {
@ -174,8 +223,8 @@
}
% if grid.sortable and grid.sort_on_backend:
for (let i = 1; i <= this.sorters.length; i++) {
params['sort'+i+'key'] = this.sorters[i-1].key
params['sort'+i+'dir'] = this.sorters[i-1].dir
params['sort'+i+'key'] = this.sorters[i-1].field
params['sort'+i+'dir'] = this.sorters[i-1].order
}
% endif
return params
@ -226,14 +275,56 @@
field = field.field
% 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
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
this.currentPage = 1
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
% if grid.paginated:

View file

@ -1124,6 +1124,7 @@ class MasterView(View):
kwargs['actions'] = actions
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_defaults', self.sort_defaults)
kwargs.setdefault('paginated', self.paginated)

View file

@ -71,10 +71,19 @@ class TestGrid(WebTestCase):
sort_defaults=[('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,
sort_multiple=True,
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):
grid = self.make_grid()
@ -236,7 +245,7 @@ class TestGrid(WebTestCase):
# can skip the saving step
self.request.GET = {'pagesize': '10', 'page': '3'}
grid.load_settings(store=False)
grid.load_settings(persist=False)
self.assertEqual(grid.page, 3)
self.assertEqual(self.request.session['grid.foo.page'], 2)
@ -261,7 +270,7 @@ class TestGrid(WebTestCase):
# can skip the saving step
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(self.request.session['grid.settings.sorters.length'], 1)
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
@ -575,21 +584,21 @@ class TestGrid(WebTestCase):
sorted_data = sorter(sample_data, 'asc')
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)
sorter = grid.make_sorter('value')
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
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)
sorter = grid.make_sorter('value', foldcase=True)
sorter = grid.make_sorter('value', foldcase=False)
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
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):
model = self.app.model
@ -632,13 +641,38 @@ class TestGrid(WebTestCase):
def test_set_sort_defaults(self):
model = self.app.model
# basics
grid = self.make_grid(model_class=model.Setting, sortable=True)
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')])
# 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):
model = self.app.model
@ -701,9 +735,9 @@ class TestGrid(WebTestCase):
sample_data = [
{'name': 'foo1', 'value': 'ONE'},
{'name': 'foo2', 'value': 'two'},
{'name': 'foo3', 'value': 'three'},
{'name': 'foo4', 'value': 'four'},
{'name': 'foo5', 'value': 'five'},
{'name': 'foo3', 'value': 'ggg'},
{'name': 'foo4', 'value': 'ggg'},
{'name': 'foo5', 'value': 'ggg'},
{'name': 'foo6', 'value': 'six'},
{'name': 'foo7', 'value': 'seven'},
{'name': 'foo8', 'value': 'eight'},
@ -740,15 +774,26 @@ class TestGrid(WebTestCase):
self.assertEqual(sorted_data[0]['name'], 'foo1')
self.assertEqual(sorted_data[-1]['name'], 'foo9')
# error if mult-column sort attempted
self.assertRaises(NotImplementedError, grid.sort_data, sample_data, sorters=[
{'key': 'name', 'dir': 'desc'},
{'key': 'value', 'dir': 'asc'},
])
# multi-column sorting for list data
sorted_data = grid.sort_data(sample_data, 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'})
# 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
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)
self.assertEqual(sorted_data[0]['name'], 'foo1')
self.assertEqual(sorted_data[-1]['name'], 'foo9')
@ -823,6 +868,24 @@ class TestGrid(WebTestCase):
self.assertEqual(first['field'], '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):
# empty if no columns defined