feat: move multi-column grid sorting logic to wuttaweb

tailbone grid template still duplicates much for Vue, and will until
we can port the filters and anything else remaining..
This commit is contained in:
Lance Edgar 2024-08-18 19:22:04 -05:00
parent ec36df4a34
commit 290f8fd51e
5 changed files with 252 additions and 292 deletions

View file

@ -850,28 +850,23 @@ class Grid(WuttaGrid):
return self.get_pagesize() return self.get_pagesize()
def load_settings(self, store=True): def load_settings(self, **kwargs):
""" """ """
Load current/effective settings for the grid, from the request query if 'store' in kwargs:
string and/or session storage. If ``store`` is true, then once warnings.warn("the 'store' param is deprecated for load_settings(); "
settings have been fully read, they are stored in current session for "please use the 'persist' param instead",
next time. Finally, various instance attributes of the grid and its DeprecationWarning, stacklevel=2)
filters are updated in-place to reflect the settings; this is so code kwargs.setdefault('persist', kwargs.pop('store'))
needn't access the settings dict directly, but the more Pythonic
instance attributes. persist = kwargs.get('persist', True)
"""
# initial default settings # initial default settings
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
@ -900,16 +895,16 @@ class Grid(WuttaGrid):
elif self.filterable and self.request_has_settings('filter'): elif self.filterable and self.request_has_settings('filter'):
self.update_filter_settings(settings, 'request') self.update_filter_settings(settings, 'request')
if self.request_has_settings('sort'): if self.request_has_settings('sort'):
self.update_sort_settings(settings, 'request') self.update_sort_settings(settings, src='request')
else: else:
self.update_sort_settings(settings, 'session') self.update_sort_settings(settings, src='session')
self.update_page_settings(settings) self.update_page_settings(settings)
# If request has no filter settings but does have sort settings, grab # If request has no filter settings but does have sort settings, grab
# those, then grab filter settings from session, then grab pager # those, then grab filter settings from session, then grab pager
# settings from request or session. # settings from request or session.
elif self.request_has_settings('sort'): elif self.request_has_settings('sort'):
self.update_sort_settings(settings, 'request') self.update_sort_settings(settings, src='request')
self.update_filter_settings(settings, 'session') self.update_filter_settings(settings, 'session')
self.update_page_settings(settings) self.update_page_settings(settings)
@ -921,26 +916,26 @@ class Grid(WuttaGrid):
elif self.request_has_settings('page'): elif self.request_has_settings('page'):
self.update_page_settings(settings) self.update_page_settings(settings)
self.update_filter_settings(settings, 'session') self.update_filter_settings(settings, 'session')
self.update_sort_settings(settings, 'session') self.update_sort_settings(settings, src='session')
# If request has no settings, grab all from session. # If request has no settings, grab all from session.
elif self.session_has_settings(): elif self.session_has_settings():
self.update_filter_settings(settings, 'session') self.update_filter_settings(settings, 'session')
self.update_sort_settings(settings, 'session') self.update_sort_settings(settings, src='session')
self.update_page_settings(settings) self.update_page_settings(settings)
# If no settings were found in request or session, don't store result. # If no settings were found in request or session, don't store result.
else: else:
store = False persist = False
# Maybe store settings for next time. # Maybe store settings for next time.
if store: if persist:
self.persist_settings(settings, 'session') self.persist_settings(settings, dest='session')
# If request contained instruction to save current settings as defaults # If request contained instruction to save current settings as defaults
# for the current user, then do that. # for the current user, then do that.
if self.request.GET.get('save-current-filters-as-defaults') == 'true': if self.request.GET.get('save-current-filters-as-defaults') == 'true':
self.persist_settings(settings, 'defaults') self.persist_settings(settings, dest='defaults')
# update ourself to reflect settings # update ourself to reflect settings
if self.filterable: if self.filterable:
@ -1107,44 +1102,6 @@ class Grid(WuttaGrid):
return any([key.startswith(f'{prefix}.filter') return any([key.startswith(f'{prefix}.filter')
for key in self.request.session]) for key in self.request.session])
def get_setting(self, source, settings, key, normalize=lambda v: v, default=None):
"""
Get the effective value for a particular setting, preferring ``source``
but falling back to existing ``settings`` and finally the ``default``.
"""
if source not in ('request', 'session'):
raise ValueError("Invalid source identifier: {}".format(source))
# If source is query string, try that first.
if source == 'request':
value = self.request.GET.get(key)
if value is not None:
try:
value = normalize(value)
except ValueError:
pass
else:
return value
# Or, if source is session, try that first.
else:
value = self.request.session.get('grid.{}.{}'.format(self.key, key))
if value is not None:
return normalize(value)
# If source had nothing, try default/existing settings.
value = settings.get(key)
if value is not None:
try:
value = normalize(value)
except ValueError:
pass
else:
return value
# Okay then, default it is.
return default
def update_filter_settings(self, settings, source): def update_filter_settings(self, settings, source):
""" """
Updates a settings dictionary according to filter settings data found Updates a settings dictionary according to filter settings data found
@ -1165,71 +1122,18 @@ class Grid(WuttaGrid):
# consider filter active if query string contains a value for it # consider filter active if query string contains a value for it
settings['{}.active'.format(prefix)] = filtr.key in self.request.GET settings['{}.active'.format(prefix)] = filtr.key in self.request.GET
settings['{}.verb'.format(prefix)] = self.get_setting( settings['{}.verb'.format(prefix)] = self.get_setting(
source, settings, '{}.verb'.format(filtr.key), default='') settings, f'{filtr.key}.verb', src='request', default='')
settings['{}.value'.format(prefix)] = self.get_setting( settings['{}.value'.format(prefix)] = self.get_setting(
source, settings, filtr.key, default='') settings, filtr.key, src='request', default='')
else: # source = session else: # source = session
settings['{}.active'.format(prefix)] = self.get_setting( settings['{}.active'.format(prefix)] = self.get_setting(
source, settings, '{}.active'.format(prefix), settings, f'{prefix}.active', src='session',
normalize=lambda v: str(v).lower() == 'true', default=False) normalize=lambda v: str(v).lower() == 'true', default=False)
settings['{}.verb'.format(prefix)] = self.get_setting( settings['{}.verb'.format(prefix)] = self.get_setting(
source, settings, '{}.verb'.format(prefix), default='') settings, f'{prefix}.verb', src='session', default='')
settings['{}.value'.format(prefix)] = self.get_setting( settings['{}.value'.format(prefix)] = self.get_setting(
source, settings, '{}.value'.format(prefix), default='') settings, f'{prefix}.value', src='session', default='')
def update_sort_settings(self, settings, source):
"""
Updates a settings dictionary according to sort settings data found in
either the GET query string, or session storage.
:param settings: Dictionary of initial settings, which is to be updated.
:param source: String identifying the source to consult for settings
data. Must be one of: ``('request', 'session')``.
"""
if not self.sortable:
return
if source == 'request':
# TODO: remove this eventually, but some links in the wild
# may still include these params, so leave it for now
if 'sortkey' in self.request.GET:
settings['sorters.length'] = 1
settings['sorters.1.key'] = self.get_setting(source, settings, 'sortkey')
settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir')
else: # the future
i = 1
while True:
skey = f'sort{i}key'
if skey in self.request.GET:
settings[f'sorters.{i}.key'] = self.get_setting(source, settings, skey)
settings[f'sorters.{i}.dir'] = self.get_setting(source, settings, f'sort{i}dir')
else:
break
i += 1
settings['sorters.length'] = i - 1
else: # session
# TODO: definitely will remove this, but leave it for now
# so it doesn't monkey with current user sessions when
# next upgrade happens. so, remove after all are upgraded
sortkey = self.get_setting(source, settings, 'sortkey')
if sortkey:
settings['sorters.length'] = 1
settings['sorters.1.key'] = sortkey
settings['sorters.1.dir'] = self.get_setting(source, settings, 'sortdir')
else: # the future
settings['sorters.length'] = self.get_setting(source, settings,
'sorters.length', int)
for i in range(1, settings['sorters.length'] + 1):
for key in ('key', 'dir'):
skey = f'sorters.{i}.{key}'
settings[skey] = self.get_setting(source, settings, skey)
def update_page_settings(self, settings): def update_page_settings(self, settings):
""" """
@ -1264,18 +1168,19 @@ class Grid(WuttaGrid):
if page is not None: if page is not None:
settings['page'] = int(page) settings['page'] = int(page)
def persist_settings(self, settings, to='session'): def persist_settings(self, settings, dest='session'):
""" """ """
Persist the given settings in some way, as defined by ``func``. if dest not in ('defaults', 'session'):
""" raise ValueError(f"invalid dest identifier: {dest}")
app = self.request.rattail_config.get_app() app = self.request.rattail_config.get_app()
model = app.model model = app.model
def persist(key, value=lambda k: settings[k]): def persist(key, value=lambda k: settings.get(k)):
if to == 'defaults': if dest == 'defaults':
skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key) skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
app.save_setting(Session(), skey, value(key)) app.save_setting(Session(), skey, value(key))
else: # to == session else: # dest == session
skey = 'grid.{}.{}'.format(self.key, key) skey = 'grid.{}.{}'.format(self.key, key)
self.request.session[skey] = value(key) self.request.session[skey] = value(key)
@ -1287,9 +1192,11 @@ class Grid(WuttaGrid):
if self.sortable: if self.sortable:
# first clear existing settings for *sorting* only # first must clear all sort settings from dest. this is
# nb. this is because number of sort settings will vary # because number of sort settings will vary, so we delete
if to == 'defaults': # all and then write all
if dest == 'defaults':
prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}' prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
query = Session.query(model.Setting)\ query = Session.query(model.Setting)\
.filter(sa.or_( .filter(sa.or_(
@ -1303,7 +1210,9 @@ class Grid(WuttaGrid):
for setting in query.all(): for setting in query.all():
Session.delete(setting) Session.delete(setting)
Session.flush() Session.flush()
else: # session else: # session
# remove sort settings from user session
prefix = f'grid.{self.key}' prefix = f'grid.{self.key}'
for key in list(self.request.session): for key in list(self.request.session):
if key.startswith(f'{prefix}.sorters.'): if key.startswith(f'{prefix}.sorters.'):
@ -1315,10 +1224,12 @@ class Grid(WuttaGrid):
self.request.session.pop(f'{prefix}.sortkey', None) self.request.session.pop(f'{prefix}.sortkey', None)
self.request.session.pop(f'{prefix}.sortdir', None) self.request.session.pop(f'{prefix}.sortdir', None)
persist('sorters.length') # now save sort settings to dest
for i in range(1, settings['sorters.length'] + 1): if 'sorters.length' in settings:
persist(f'sorters.{i}.key') persist('sorters.length')
persist(f'sorters.{i}.dir') for i in range(1, settings['sorters.length'] + 1):
persist(f'sorters.{i}.key')
persist(f'sorters.{i}.dir')
if self.paginated: if self.paginated:
persist('pagesize') persist('pagesize')
@ -1351,58 +1262,32 @@ class Grid(WuttaGrid):
if not sorters: if not sorters:
return data return data
# sqlalchemy queries require special handling, in case of # nb. when data is a query, we want to apply sorters in the
# multi-column sorting # requested order, so the final query has order_by() in the
if isinstance(data, orm.Query): # 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)
# collect actual column sorters for order_by clause for sorter in sorters:
query_sorters = [] sortkey = sorter['key']
for sorter in sorters: sortdir = sorter['dir']
sortkey = sorter['key']
sortdir = sorter['dir']
# cannot sort unless we have a sorter callable # cannot sort unless we have a sorter callable
sortfunc = self.sorters.get(sortkey) sortfunc = self.sorters.get(sortkey)
if not sortfunc: if not sortfunc:
log.warning("unknown sorter: %s", sorter) return data
continue
# join appropriate model if needed # join appropriate model if needed
if sortkey in self.joiners and sortkey not in self.joined: if sortkey in self.joiners and sortkey not in self.joined:
data = self.joiners[sortkey](data) data = self.joiners[sortkey](data)
self.joined.add(sortkey) self.joined.add(sortkey)
# add column/dir to collection # invoke the sorter
query_sorters.append(getattr(sortfunc._column, sortdir)()) data = sortfunc(data, sortdir)
# apply sorting to query return data
if query_sorters:
data = data.order_by(*query_sorters)
return data
# manual sorting; only one column allowed
if len(sorters) != 1:
raise NotImplementedError("mulit-column manual sorting not yet supported")
# our one and only active sorter
sorter = sorters[0]
sortkey = sorter['key']
sortdir = sorter['dir']
# cannot sort unless we have a sorter callable
sortfunc = self.sorters.get(sortkey)
if not sortfunc:
return data
# apply joins needed for this sorter
# TODO: is this actually relevant for manual sort?
if sortkey in self.joiners and sortkey not in self.joined:
data = self.joiners[sortkey](data)
self.joined.add(sortkey)
# invoke the sorter
return sortfunc(data, sortdir)
def paginate_data(self, data): def paginate_data(self, data):
""" """

View file

@ -658,19 +658,19 @@
## TODO: is there a better way to check if viewing parent? ## TODO: is there a better way to check if viewing parent?
% if parent_instance is Undefined: % if parent_instance is Undefined:
% if master.editable and instance_editable and master.has_perm('edit'): % if master.editable and instance_editable and master.has_perm('edit'):
<once-button tag="a" href="${action_url('edit', instance)}" <once-button tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit" icon-left="edit"
text="Edit This"> text="Edit This">
</once-button> </once-button>
% endif % endif
% if master.cloneable and master.has_perm('clone'): % if getattr(master, 'cloneable', False) and master.has_perm('clone'):
<once-button tag="a" href="${action_url('clone', instance)}" <once-button tag="a" href="${master.get_action_url('clone', instance)}"
icon-left="object-ungroup" icon-left="object-ungroup"
text="Clone This"> text="Clone This">
</once-button> </once-button>
% endif % endif
% if master.deletable and instance_deletable and master.has_perm('delete'): % if master.deletable and instance_deletable and master.has_perm('delete'):
<once-button tag="a" href="${action_url('delete', instance)}" <once-button tag="a" href="${master.get_action_url('delete', instance)}"
type="is-danger" type="is-danger"
icon-left="trash" icon-left="trash"
text="Delete This"> text="Delete This">
@ -679,7 +679,7 @@
% else: % else:
## viewing row ## viewing row
% if instance_deletable and master.has_perm('delete_row'): % if instance_deletable and master.has_perm('delete_row'):
<once-button tag="a" href="${action_url('delete', instance)}" <once-button tag="a" href="${master.get_action_url('delete', instance)}"
type="is-danger" type="is-danger"
icon-left="trash" icon-left="trash"
text="Delete This"> text="Delete This">
@ -688,13 +688,13 @@
% endif % endif
% elif master and master.editing: % elif master and master.editing:
% if master.viewable and master.has_perm('view'): % if master.viewable and master.has_perm('view'):
<once-button tag="a" href="${action_url('view', instance)}" <once-button tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye" icon-left="eye"
text="View This"> text="View This">
</once-button> </once-button>
% endif % endif
% if master.deletable and instance_deletable and master.has_perm('delete'): % if master.deletable and instance_deletable and master.has_perm('delete'):
<once-button tag="a" href="${action_url('delete', instance)}" <once-button tag="a" href="${master.get_action_url('delete', instance)}"
type="is-danger" type="is-danger"
icon-left="trash" icon-left="trash"
text="Delete This"> text="Delete This">
@ -702,13 +702,13 @@
% endif % endif
% elif master and master.deleting: % elif master and master.deleting:
% if master.viewable and master.has_perm('view'): % if master.viewable and master.has_perm('view'):
<once-button tag="a" href="${action_url('view', instance)}" <once-button tag="a" href="${master.get_action_url('view', instance)}"
icon-left="eye" icon-left="eye"
text="View This"> text="View This">
</once-button> </once-button>
% endif % endif
% if master.editable and instance_editable and master.has_perm('edit'): % if master.editable and instance_editable and master.has_perm('edit'):
<once-button tag="a" href="${action_url('edit', instance)}" <once-button tag="a" href="${master.get_action_url('edit', instance)}"
icon-left="edit" icon-left="edit"
text="Edit This"> text="Edit This">
</once-button> </once-button>

View file

@ -83,26 +83,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:
backend-sorting backend-sorting
@sort="onSort" @sort="onSort"
@sorting-priority-removed="sortingPriorityRemoved" % endif
% if grid.sort_multiple:
## TODO: there is a bug (?) which prevents the arrow from % if grid.sort_on_backend:
## displaying for simple default single-column sort. so to ## TODO: there is a bug (?) which prevents the arrow
## work around that, we *disable* multi-sort until the ## from displaying for simple default single-column sort,
## component is mounted. seems to work for now..see also ## when multi-column sort is allowed for the table. for
## https://github.com/buefy/buefy/issues/2584 ## now we work around that by waiting until mount to
:sort-multiple="allowMultiSort" ## enable the multi-column support. see also
## https://github.com/buefy/buefy/issues/2584
:sort-multiple="allowMultiSort"
## nb. otherwise there may be default multi-column sort :sort-multiple-data="sortingPriority"
:sort-multiple-data="sortingPriority" @sorting-priority-removed="sortingPriorityRemoved"
% else:
## user must ctrl-click column header to do multi-sort sort-multiple
sort-multiple-key="ctrlKey" % endif
## nb. user must ctrl-click column header for multi-sort
sort-multiple-key="ctrlKey"
% endif
% endif % endif
% if getattr(grid, 'click_handlers', None): % if getattr(grid, 'click_handlers', None):
@ -276,23 +279,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:
## TODO: there is a bug (?) which prevents the arrow from % if grid.sort_on_backend:
## displaying for simple default single-column sort. so to ## TODO: there is a bug (?) which prevents the arrow
## work around that, we *disable* multi-sort until the ## from displaying for simple default single-column sort,
## component is mounted. seems to work for now..see also ## when multi-column sort is allowed for the table. for
## https://github.com/buefy/buefy/issues/2584 ## now we work around that by waiting until mount to
allowMultiSort: false, ## enable the multi-column support. see also
## https://github.com/buefy/buefy/issues/2584
## nb. this will only contain multi-column sorters, allowMultiSort: false,
## but will be *empty* for single-column sorting ## nb. this should be empty when current sort is single-column
% if len(grid.active_sorters) > 1: % if len(grid.active_sorters) > 1:
sortingPriority: ${json.dumps(grid.active_sorters)|n}, sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n},
% else: % else:
sortingPriority: [], sortingPriority: [],
% endif
% endif
% endif % endif
% endif % endif
## filterable: ${json.dumps(grid.filterable)|n}, ## filterable: ${json.dumps(grid.filterable)|n},
@ -395,14 +399,19 @@
}, },
}, },
mounted() { % 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. so to ## TODO: there is a bug (?) which prevents the arrow
## work around that, we *disable* multi-sort until the ## from displaying for simple default single-column sort,
## component is mounted. seems to work for now..see also ## when multi-column sort is allowed for the table. for
## https://github.com/buefy/buefy/issues/2584 ## now we work around that by waiting until mount to
this.allowMultiSort = true ## enable the multi-column support. see also
}, ## https://github.com/buefy/buefy/issues/2584
mounted() {
this.allowMultiSort = true
},
% endif
methods: { methods: {
@ -483,8 +492,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
@ -597,48 +606,66 @@
}) })
}, },
onSort(field, order, event) { % if grid.sortable and grid.sort_on_backend:
## nb. buefy passes field name; oruga passes field object onSort(field, order, event) {
% if request.use_oruga:
field = field.field
% endif
if (event.ctrlKey) { ## nb. buefy passes field name; oruga passes field object
% if request.use_oruga:
field = field.field
% endif
// engage or enhance multi-column sorting % if grid.sort_multiple:
const sorter = this.sorters.filter(s => s.key === field)[0]
if (sorter) {
sorter.dir = sorter.dir === 'desc' ? 'asc' : 'desc'
} else {
this.sorters.push({key: field, dir: order})
}
this.sortingPriority = this.sorters
} else { // 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}]
this.sortingPriority = []
}
// always reset to first page when changing sort options % if grid.sort_multiple:
// TODO: i mean..right? would we ever not want that? // multi-column sort not engaged
this.currentPage = 1 this.sortingPriority = []
this.loadAsyncData() }
}, % endif
sortingPriorityRemoved(field) { // nb. always reset to first page when sorting changes
this.currentPage = 1
this.loadAsyncData()
},
// prune field from active sorters % if grid.sort_multiple:
this.sorters = this.sorters.filter(s => s.key !== field)
// nb. must keep active sorter list "as-is" even if sortingPriorityRemoved(field) {
// there is only one sorter; buefy seems to expect it
this.sortingPriority = this.sorters
this.loadAsyncData() // 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.loadAsyncData()
},
% endif
% endif
resetView() { resetView() {
this.loading = true this.loading = true

View file

@ -341,7 +341,7 @@ class MasterView(View):
return self.redirect(self.request.current_route_url(**kw)) return self.redirect(self.request.current_route_url(**kw))
# Stash some grid stats, for possible use when generating URLs. # Stash some grid stats, for possible use when generating URLs.
if grid.pageable and hasattr(grid, 'pager'): if grid.paginated and hasattr(grid, 'pager'):
self.first_visible_grid_index = grid.pager.first_item self.first_visible_grid_index = grid.pager.first_item
# return grid data only, if partial page was requested # return grid data only, if partial page was requested
@ -442,6 +442,7 @@ class MasterView(View):
'filterable': self.filterable, 'filterable': self.filterable,
'use_byte_string_filters': self.use_byte_string_filters, 'use_byte_string_filters': self.use_byte_string_filters,
'sortable': self.sortable, 'sortable': self.sortable,
'sort_multiple': not self.request.use_oruga,
'paginated': self.pageable, 'paginated': self.pageable,
'extra_row_class': self.grid_extra_class, 'extra_row_class': self.grid_extra_class,
'url': lambda obj: self.get_action_url('view', obj), 'url': lambda obj: self.get_action_url('view', obj),

View file

@ -388,14 +388,63 @@ class TestGrid(WebTestCase):
grid.load_settings() grid.load_settings()
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}]) self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
def test_persist_settings(self):
model = self.app.model
# nb. start out with paginated-only grid
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
# invalid dest
self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist')
# nb. no error if empty settings, but it saves null values
grid.persist_settings({}, dest='session')
self.assertIsNone(self.request.session['grid.foo.page'])
# provided values are saved
grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session')
self.assertEqual(self.request.session['grid.foo.page'], 3)
# nb. now switch to sortable-only grid
grid = self.make_grid(key='settings', model_class=model.Setting,
sortable=True, sort_on_backend=True)
# no error if empty settings; does not save values
grid.persist_settings({}, dest='session')
self.assertNotIn('grid.settings.sorters.length', self.request.session)
# provided values are saved
grid.persist_settings({'sorters.length': 2,
'sorters.1.key': 'name',
'sorters.1.dir': 'desc',
'sorters.2.key': 'value',
'sorters.2.dir': 'asc'},
dest='session')
self.assertEqual(self.request.session['grid.settings.sorters.length'], 2)
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value')
self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc')
# old values removed when new are saved
grid.persist_settings({'sorters.length': 1,
'sorters.1.key': 'name',
'sorters.1.dir': 'desc'},
dest='session')
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.dir'], 'desc')
self.assertNotIn('grid.settings.sorters.2.key', self.request.session)
self.assertNotIn('grid.settings.sorters.2.dir', self.request.session)
def test_sort_data(self): def test_sort_data(self):
model = self.app.model model = self.app.model
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'},
@ -432,32 +481,30 @@ 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')
# cannot sort data if sortfunc is missing for column
grid.remove_sorter('name')
# nb. attempting multi-column sort, but only one sorter exists
self.assertEqual(list(grid.sorters), ['value'])
grid.active_sorters = [{'key': 'name', 'dir': 'asc'},
{'key': 'value', 'dir': 'asc'}]
with patch.object(sample_query, 'order_by') as order_by:
order_by.return_value = 42
sorted_query = grid.sort_data(sample_query)
order_by.assert_called_once()
self.assertEqual(len(order_by.call_args.args), 1)
self.assertEqual(sorted_query, 42)
def test_render_vue_tag(self): def test_render_vue_tag(self):
model = self.app.model model = self.app.model