feat: move single-column grid sorting logic to wuttaweb
This commit is contained in:
parent
c95e42bf82
commit
ec36df4a34
|
@ -47,7 +47,7 @@ from pyramid_deform import SessionFileUploadTempStore
|
|||
from pyramid.renderers import render
|
||||
from webhelpers2.html import tags, HTML
|
||||
|
||||
from wuttaweb.util import get_form_data, make_json_safe
|
||||
from wuttaweb.util import FieldList, get_form_data, make_json_safe
|
||||
|
||||
from tailbone.db import Session
|
||||
from tailbone.util import raw_datetime, render_markdown
|
||||
|
@ -1418,30 +1418,6 @@ class Form(object):
|
|||
return False
|
||||
|
||||
|
||||
class FieldList(list):
|
||||
"""
|
||||
Convenience wrapper for a form's field list.
|
||||
"""
|
||||
|
||||
def insert_before(self, field, newfield):
|
||||
if field in self:
|
||||
i = self.index(field)
|
||||
self.insert(i, newfield)
|
||||
else:
|
||||
log.warning("field '%s' not found, will append new field: %s",
|
||||
field, newfield)
|
||||
self.append(newfield)
|
||||
|
||||
def insert_after(self, field, newfield):
|
||||
if field in self:
|
||||
i = self.index(field)
|
||||
self.insert(i + 1, newfield)
|
||||
else:
|
||||
log.warning("field '%s' not found, will append new field: %s",
|
||||
field, newfield)
|
||||
self.append(newfield)
|
||||
|
||||
|
||||
@colander.deferred
|
||||
def upload_widget(node, kw):
|
||||
request = kw['request']
|
||||
|
|
|
@ -39,7 +39,8 @@ from pyramid.renderers import render
|
|||
from webhelpers2.html import HTML, tags
|
||||
from paginate_sqlalchemy import SqlalchemyOrmPage
|
||||
|
||||
from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction
|
||||
from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction, SortInfo
|
||||
from wuttaweb.util import FieldList
|
||||
from . import filters as gridfilters
|
||||
from tailbone.db import Session
|
||||
from tailbone.util import raw_datetime
|
||||
|
@ -48,23 +49,17 @@ from tailbone.util import raw_datetime
|
|||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FieldList(list):
|
||||
"""
|
||||
Convenience wrapper for a field list.
|
||||
"""
|
||||
|
||||
def insert_before(self, field, newfield):
|
||||
i = self.index(field)
|
||||
self.insert(i, newfield)
|
||||
|
||||
def insert_after(self, field, newfield):
|
||||
i = self.index(field)
|
||||
self.insert(i + 1, newfield)
|
||||
|
||||
|
||||
class Grid(WuttaGrid):
|
||||
"""
|
||||
Core grid class. In sore need of documentation.
|
||||
Base class for all grids.
|
||||
|
||||
This is now a subclass of
|
||||
:class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add
|
||||
customizations which have traditionally been part of Tailbone.
|
||||
|
||||
Some of these customizations are still undocumented. Some will
|
||||
eventually be moved to the upstream/parent class, and possibly
|
||||
some will be removed outright. What docs we have, are shown here.
|
||||
|
||||
.. _Buefy docs: https://buefy.org/documentation/table/
|
||||
|
||||
|
@ -206,10 +201,6 @@ class Grid(WuttaGrid):
|
|||
filters={},
|
||||
use_byte_string_filters=False,
|
||||
searchable={},
|
||||
sortable=False,
|
||||
sorters={},
|
||||
default_sortkey=None,
|
||||
default_sortdir='asc',
|
||||
checkboxes=False,
|
||||
checked=None,
|
||||
check_handler=None,
|
||||
|
@ -231,6 +222,20 @@ class Grid(WuttaGrid):
|
|||
DeprecationWarning, stacklevel=2)
|
||||
kwargs.setdefault('vue_tagname', kwargs.pop('component'))
|
||||
|
||||
if kwargs.get('default_sortkey'):
|
||||
warnings.warn("default_sortkey param is deprecated for Grid(); "
|
||||
"please use sort_defaults param instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
if kwargs.get('default_sortdir'):
|
||||
warnings.warn("default_sortdir param is deprecated for Grid(); "
|
||||
"please use sort_defaults param instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
if kwargs.get('default_sortkey') or kwargs.get('default_sortdir'):
|
||||
sortkey = kwargs.pop('default_sortkey', None)
|
||||
sortdir = kwargs.pop('default_sortdir', 'asc')
|
||||
if sortkey:
|
||||
kwargs.setdefault('sort_defaults', [(sortkey, sortdir)])
|
||||
|
||||
if kwargs.get('pageable'):
|
||||
warnings.warn("component param is deprecated for Grid(); "
|
||||
"please use vue_tagname param instead",
|
||||
|
@ -284,11 +289,6 @@ class Grid(WuttaGrid):
|
|||
|
||||
self.searchable = searchable or {}
|
||||
|
||||
self.sortable = sortable
|
||||
self.sorters = self.make_sorters(sorters)
|
||||
self.default_sortkey = default_sortkey
|
||||
self.default_sortdir = default_sortdir
|
||||
|
||||
self.checkboxes = checkboxes
|
||||
self.checked = checked
|
||||
if self.checked is None:
|
||||
|
@ -328,9 +328,7 @@ class Grid(WuttaGrid):
|
|||
|
||||
@property
|
||||
def component(self):
|
||||
"""
|
||||
DEPRECATED - use :attr:`vue_tagname` instead.
|
||||
"""
|
||||
""" """
|
||||
warnings.warn("Grid.component is deprecated; "
|
||||
"please use vue_tagname instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
|
@ -338,20 +336,66 @@ class Grid(WuttaGrid):
|
|||
|
||||
@property
|
||||
def component_studly(self):
|
||||
"""
|
||||
DEPRECATED - use :attr:`vue_component` instead.
|
||||
"""
|
||||
""" """
|
||||
warnings.warn("Grid.component_studly is deprecated; "
|
||||
"please use vue_component instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
return self.vue_component
|
||||
|
||||
def get_default_sortkey(self):
|
||||
""" """
|
||||
warnings.warn("Grid.default_sortkey is deprecated; "
|
||||
"please use Grid.sort_defaults instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
if self.sort_defaults:
|
||||
return self.sort_defaults[0].sortkey
|
||||
|
||||
def set_default_sortkey(self, value):
|
||||
""" """
|
||||
warnings.warn("Grid.default_sortkey is deprecated; "
|
||||
"please use Grid.sort_defaults instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
if self.sort_defaults:
|
||||
info = self.sort_defaults[0]
|
||||
self.sort_defaults[0] = SortInfo(value, info.sortdir)
|
||||
else:
|
||||
self.sort_defaults = [SortInfo(value, 'asc')]
|
||||
|
||||
default_sortkey = property(get_default_sortkey, set_default_sortkey)
|
||||
|
||||
def get_default_sortdir(self):
|
||||
""" """
|
||||
warnings.warn("Grid.default_sortdir is deprecated; "
|
||||
"please use Grid.sort_defaults instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
if self.sort_defaults:
|
||||
return self.sort_defaults[0].sortdir
|
||||
|
||||
def set_default_sortdir(self, value):
|
||||
""" """
|
||||
warnings.warn("Grid.default_sortdir is deprecated; "
|
||||
"please use Grid.sort_defaults instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
if self.sort_defaults:
|
||||
info = self.sort_defaults[0]
|
||||
self.sort_defaults[0] = SortInfo(info.sortkey, value)
|
||||
else:
|
||||
raise ValueError("cannot set default_sortdir without default_sortkey")
|
||||
|
||||
default_sortdir = property(get_default_sortdir, set_default_sortdir)
|
||||
|
||||
def get_pageable(self):
|
||||
""" """
|
||||
warnings.warn("Grid.pageable is deprecated; "
|
||||
"please use Grid.paginated instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
return self.paginated
|
||||
|
||||
def set_pageable(self, value):
|
||||
""" """
|
||||
warnings.warn("Grid.pageable is deprecated; "
|
||||
"please use Grid.paginated instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
self.paginated = value
|
||||
|
||||
pageable = property(get_pageable, set_pageable)
|
||||
|
@ -405,18 +449,30 @@ class Grid(WuttaGrid):
|
|||
self.joiners[key] = joiner
|
||||
|
||||
def set_sorter(self, key, *args, **kwargs):
|
||||
if len(args) == 1 and args[0] is None:
|
||||
""" """
|
||||
|
||||
if len(args) == 1:
|
||||
if kwargs:
|
||||
warnings.warn("kwargs are ignored for Grid.set_sorter(); "
|
||||
"please refactor your code accordingly",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
if args[0] is None:
|
||||
warnings.warn("specifying None is deprecated for Grid.set_sorter(); "
|
||||
"please use Grid.remove_sorter() instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
self.remove_sorter(key)
|
||||
else:
|
||||
super().set_sorter(key, args[0])
|
||||
|
||||
elif len(args) == 0:
|
||||
super().set_sorter(key)
|
||||
|
||||
else:
|
||||
warnings.warn("multiple args are deprecated for Grid.set_sorter(); "
|
||||
"please refactor your code accordingly",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
self.sorters[key] = self.make_sorter(*args, **kwargs)
|
||||
|
||||
def remove_sorter(self, key):
|
||||
self.sorters.pop(key, None)
|
||||
|
||||
def set_sort_defaults(self, sortkey, sortdir='asc'):
|
||||
self.default_sortkey = sortkey
|
||||
self.default_sortdir = sortdir
|
||||
|
||||
def set_filter(self, key, *args, **kwargs):
|
||||
if len(args) == 1 and args[0] is None:
|
||||
self.remove_filter(key)
|
||||
|
@ -731,53 +787,12 @@ class Grid(WuttaGrid):
|
|||
if filtr.active:
|
||||
yield filtr
|
||||
|
||||
def make_sorters(self, sorters=None):
|
||||
"""
|
||||
Returns an initial set of sorters which will be available to the grid.
|
||||
The grid itself may or may not provide some default sorters, and the
|
||||
``sorters`` kwarg may contain additions and/or overrides.
|
||||
"""
|
||||
sorters, updates = {}, sorters
|
||||
if self.model_class:
|
||||
mapper = orm.class_mapper(self.model_class)
|
||||
for prop in mapper.iterate_properties:
|
||||
if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
|
||||
sorters[prop.key] = self.make_sorter(prop)
|
||||
if updates:
|
||||
sorters.update(updates)
|
||||
return sorters
|
||||
|
||||
def make_sorter(self, model_property):
|
||||
"""
|
||||
Returns a function suitable for a sort map callable, with typical logic
|
||||
built in for sorting applied to ``field``.
|
||||
"""
|
||||
class_ = getattr(model_property, 'class_', self.model_class)
|
||||
column = getattr(class_, model_property.key)
|
||||
|
||||
def sorter(query, direction):
|
||||
# TODO: this seems hacky..normally we expect a true query
|
||||
# of course, but in some cases it may be a list instead.
|
||||
# if so then we can't actually sort
|
||||
if isinstance(query, list):
|
||||
return query
|
||||
return query.order_by(getattr(column, direction)())
|
||||
|
||||
sorter._class = class_
|
||||
sorter._column = column
|
||||
|
||||
return sorter
|
||||
|
||||
def make_simple_sorter(self, key, foldcase=False):
|
||||
"""
|
||||
Returns a function suitable for a sort map callable, with typical logic
|
||||
built in for sorting a data set comprised of dicts, on the given key.
|
||||
"""
|
||||
if foldcase:
|
||||
keyfunc = lambda v: v[key].lower()
|
||||
else:
|
||||
keyfunc = lambda v: v[key]
|
||||
return lambda q, d: sorted(q, key=keyfunc, reverse=d == 'desc')
|
||||
""" """
|
||||
warnings.warn("Grid.make_simple_sorter() is deprecated; "
|
||||
"please use Grid.make_sorter() instead",
|
||||
DeprecationWarning, stacklevel=2)
|
||||
return self.make_sorter(key, foldcase=foldcase)
|
||||
|
||||
def get_pagesize_options(self, default=None):
|
||||
""" """
|
||||
|
@ -849,10 +864,17 @@ class Grid(WuttaGrid):
|
|||
# initial default settings
|
||||
settings = {}
|
||||
if self.sortable:
|
||||
if self.default_sortkey:
|
||||
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]
|
||||
settings['sorters.length'] = 1
|
||||
settings['sorters.1.key'] = self.default_sortkey
|
||||
settings['sorters.1.dir'] = self.default_sortdir
|
||||
settings['sorters.1.key'] = sortinfo.sortkey
|
||||
settings['sorters.1.dir'] = sortinfo.sortdir
|
||||
else:
|
||||
settings['sorters.length'] = 0
|
||||
if self.paginated:
|
||||
|
@ -927,11 +949,12 @@ class Grid(WuttaGrid):
|
|||
filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
|
||||
filtr.value = settings['filter.{}.value'.format(filtr.key)]
|
||||
if self.sortable:
|
||||
# and self.sort_on_backend:
|
||||
self.active_sorters = []
|
||||
for i in range(1, settings['sorters.length'] + 1):
|
||||
self.active_sorters.append({
|
||||
'field': settings[f'sorters.{i}.key'],
|
||||
'order': settings[f'sorters.{i}.dir'],
|
||||
'key': settings[f'sorters.{i}.key'],
|
||||
'dir': settings[f'sorters.{i}.dir'],
|
||||
})
|
||||
if self.paginated:
|
||||
self.pagesize = settings['pagesize']
|
||||
|
@ -1321,21 +1344,24 @@ class Grid(WuttaGrid):
|
|||
|
||||
return data
|
||||
|
||||
def sort_data(self, data):
|
||||
"""
|
||||
Sort the given query according to current settings, and return the result.
|
||||
"""
|
||||
# bail if no sort settings
|
||||
if not self.active_sorters:
|
||||
def sort_data(self, data, sorters=None):
|
||||
""" """
|
||||
if sorters is None:
|
||||
sorters = self.active_sorters
|
||||
if not sorters:
|
||||
return data
|
||||
|
||||
# TODO: is there a better way to check for SA sorting?
|
||||
if self.model_class:
|
||||
# sqlalchemy queries require special handling, in case of
|
||||
# multi-column sorting
|
||||
if isinstance(data, orm.Query):
|
||||
|
||||
# collect actual column sorters for order_by clause
|
||||
sorters = []
|
||||
for sorter in self.active_sorters:
|
||||
sortkey = sorter['field']
|
||||
query_sorters = []
|
||||
for sorter in sorters:
|
||||
sortkey = sorter['key']
|
||||
sortdir = sorter['dir']
|
||||
|
||||
# cannot sort unless we have a sorter callable
|
||||
sortfunc = self.sorters.get(sortkey)
|
||||
if not sortfunc:
|
||||
log.warning("unknown sorter: %s", sorter)
|
||||
|
@ -1347,33 +1373,35 @@ class Grid(WuttaGrid):
|
|||
self.joined.add(sortkey)
|
||||
|
||||
# add column/dir to collection
|
||||
sortdir = sorter['order']
|
||||
sorters.append(getattr(sortfunc._column, sortdir)())
|
||||
query_sorters.append(getattr(sortfunc._column, sortdir)())
|
||||
|
||||
# apply sorting to query
|
||||
if sorters:
|
||||
data = data.order_by(*sorters)
|
||||
if query_sorters:
|
||||
data = data.order_by(*query_sorters)
|
||||
|
||||
return data
|
||||
|
||||
else:
|
||||
# not a SQLAlchemy grid, custom sorter
|
||||
# manual sorting; only one column allowed
|
||||
if len(sorters) != 1:
|
||||
raise NotImplementedError("mulit-column manual sorting not yet supported")
|
||||
|
||||
assert len(self.active_sorters) < 2
|
||||
# our one and only active sorter
|
||||
sorter = sorters[0]
|
||||
sortkey = sorter['key']
|
||||
sortdir = sorter['dir']
|
||||
|
||||
sortkey = self.active_sorters[0]['field']
|
||||
sortdir = self.active_sorters[0]['order'] or 'asc'
|
||||
|
||||
# Cannot sort unless we have a sort function.
|
||||
# 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):
|
||||
|
@ -1671,7 +1699,7 @@ class Grid(WuttaGrid):
|
|||
columns.append({
|
||||
'field': name,
|
||||
'label': self.get_label(name),
|
||||
'sortable': self.sortable and name in self.sorters,
|
||||
'sortable': self.is_sortable(name),
|
||||
'visible': name not in self.invisible,
|
||||
})
|
||||
return columns
|
||||
|
|
|
@ -81,7 +81,11 @@
|
|||
% endif
|
||||
% endif
|
||||
|
||||
% if getattr(grid, 'sortable', False):
|
||||
## sorting
|
||||
% if grid.sortable:
|
||||
## nb. buefy only supports *one* default sorter
|
||||
:default-sort="sorters.length ? [sorters[0].key, sorters[0].dir] : null"
|
||||
|
||||
backend-sorting
|
||||
@sort="onSort"
|
||||
@sorting-priority-removed="sortingPriorityRemoved"
|
||||
|
@ -93,8 +97,6 @@
|
|||
## https://github.com/buefy/buefy/issues/2584
|
||||
:sort-multiple="allowMultiSort"
|
||||
|
||||
## nb. specify default sort only if single-column
|
||||
:default-sort="backendSorters.length == 1 ? [backendSorters[0].field, backendSorters[0].order] : null"
|
||||
|
||||
## nb. otherwise there may be default multi-column sort
|
||||
:sort-multiple-data="sortingPriority"
|
||||
|
@ -272,7 +274,9 @@
|
|||
% endif
|
||||
% endif
|
||||
|
||||
% if getattr(grid, 'sortable', False):
|
||||
## sorting
|
||||
% if grid.sortable:
|
||||
sorters: ${json.dumps(grid.active_sorters)|n},
|
||||
|
||||
## TODO: there is a bug (?) which prevents the arrow from
|
||||
## displaying for simple default single-column sort. so to
|
||||
|
@ -281,10 +285,7 @@
|
|||
## https://github.com/buefy/buefy/issues/2584
|
||||
allowMultiSort: false,
|
||||
|
||||
## nb. this contains all truly active sorters
|
||||
backendSorters: ${json.dumps(grid.active_sorters)|n},
|
||||
|
||||
## nb. whereas this will only contain multi-column sorters,
|
||||
## nb. this will only contain multi-column sorters,
|
||||
## but will be *empty* for single-column sorting
|
||||
% if len(grid.active_sorters) > 1:
|
||||
sortingPriority: ${json.dumps(grid.active_sorters)|n},
|
||||
|
@ -474,16 +475,17 @@
|
|||
},
|
||||
|
||||
getBasicParams() {
|
||||
let params = {}
|
||||
% if getattr(grid, 'sortable', False):
|
||||
for (let i = 1; i <= this.backendSorters.length; i++) {
|
||||
params['sort'+i+'key'] = this.backendSorters[i-1].field
|
||||
params['sort'+i+'dir'] = this.backendSorters[i-1].order
|
||||
}
|
||||
const params = {
|
||||
% if grid.paginated and grid.paginate_on_backend:
|
||||
pagesize: this.perPage,
|
||||
page: this.currentPage,
|
||||
% endif
|
||||
% if grid.paginated:
|
||||
params.pagesize = this.perPage
|
||||
params.page = this.currentPage
|
||||
}
|
||||
% 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
|
||||
}
|
||||
% endif
|
||||
return params
|
||||
},
|
||||
|
@ -526,15 +528,15 @@
|
|||
this.loading = true
|
||||
this.$http.get(`${'$'}{this.ajaxDataUrl}?${'$'}{params}`).then(response => {
|
||||
if (!response.data.error) {
|
||||
${grid.vue_component}CurrentData = response.data.data.data
|
||||
${grid.vue_component}CurrentData = response.data.data
|
||||
this.data = ${grid.vue_component}CurrentData
|
||||
% if grid.paginated and grid.paginate_on_backend:
|
||||
this.pagerStats = response.data.pager_stats
|
||||
% endif
|
||||
this.rowStatusMap = response.data.data.row_status_map
|
||||
this.rowStatusMap = response.data.row_status_map || {}
|
||||
this.loading = false
|
||||
this.savingDefaults = false
|
||||
this.checkedRows = this.locateCheckedRows(response.data.data.checked_rows)
|
||||
this.checkedRows = this.locateCheckedRows(response.data.checked_rows || [])
|
||||
if (success) {
|
||||
success()
|
||||
}
|
||||
|
@ -597,26 +599,26 @@
|
|||
|
||||
onSort(field, order, event) {
|
||||
|
||||
// nb. buefy passes field name, oruga passes object
|
||||
if (field.field) {
|
||||
## nb. buefy passes field name; oruga passes field object
|
||||
% if request.use_oruga:
|
||||
field = field.field
|
||||
}
|
||||
% endif
|
||||
|
||||
if (event.ctrlKey) {
|
||||
|
||||
// engage or enhance multi-column sorting
|
||||
let sorter = this.backendSorters.filter(i => i.field === field)[0]
|
||||
const sorter = this.sorters.filter(s => s.key === field)[0]
|
||||
if (sorter) {
|
||||
sorter.order = sorter.order === 'desc' ? 'asc' : 'desc'
|
||||
sorter.dir = sorter.dir === 'desc' ? 'asc' : 'desc'
|
||||
} else {
|
||||
this.backendSorters.push({field, order})
|
||||
this.sorters.push({key: field, dir: order})
|
||||
}
|
||||
this.sortingPriority = this.backendSorters
|
||||
this.sortingPriority = this.sorters
|
||||
|
||||
} else {
|
||||
|
||||
// sort by single column only
|
||||
this.backendSorters = [{field, order}]
|
||||
this.sorters = [{key: field, dir: order}]
|
||||
this.sortingPriority = []
|
||||
}
|
||||
|
||||
|
@ -629,12 +631,11 @@
|
|||
sortingPriorityRemoved(field) {
|
||||
|
||||
// prune field from active sorters
|
||||
this.backendSorters = this.backendSorters.filter(
|
||||
(sorter) => sorter.field !== field)
|
||||
this.sorters = this.sorters.filter(s => s.key !== field)
|
||||
|
||||
// nb. must keep active sorter list "as-is" even if
|
||||
// there is only one sorter; buefy seems to expect it
|
||||
this.sortingPriority = this.backendSorters
|
||||
this.sortingPriority = this.sorters
|
||||
|
||||
this.loadAsyncData()
|
||||
},
|
||||
|
|
|
@ -345,8 +345,8 @@ class MasterView(View):
|
|||
self.first_visible_grid_index = grid.pager.first_item
|
||||
|
||||
# return grid data only, if partial page was requested
|
||||
if self.request.params.get('partial'):
|
||||
context = {'data': grid.get_table_data()}
|
||||
if self.request.GET.get('partial'):
|
||||
context = grid.get_table_data()
|
||||
if grid.paginated and grid.paginate_on_backend:
|
||||
context['pager_stats'] = grid.get_vue_pager_stats()
|
||||
return self.json_response(context)
|
||||
|
@ -2565,11 +2565,12 @@ class MasterView(View):
|
|||
so if you like you can return a different help URL depending on which
|
||||
type of CRUD view is in effect, etc.
|
||||
"""
|
||||
# nb. self.Session may differ, so use tailbone.db.Session
|
||||
session = Session()
|
||||
model = self.model
|
||||
route_prefix = self.get_route_prefix()
|
||||
|
||||
# nb. self.Session may differ, so use tailbone.db.Session
|
||||
info = Session.query(model.TailbonePageHelp)\
|
||||
info = session.query(model.TailbonePageHelp)\
|
||||
.filter(model.TailbonePageHelp.route_prefix == route_prefix)\
|
||||
.first()
|
||||
if info and info.help_url:
|
||||
|
@ -2587,11 +2588,12 @@ class MasterView(View):
|
|||
"""
|
||||
Return the markdown help text for current page, if defined.
|
||||
"""
|
||||
# nb. self.Session may differ, so use tailbone.db.Session
|
||||
session = Session()
|
||||
model = self.model
|
||||
route_prefix = self.get_route_prefix()
|
||||
|
||||
# nb. self.Session may differ, so use tailbone.db.Session
|
||||
info = Session.query(model.TailbonePageHelp)\
|
||||
info = session.query(model.TailbonePageHelp)\
|
||||
.filter(model.TailbonePageHelp.route_prefix == route_prefix)\
|
||||
.first()
|
||||
if info and info.markdown_text:
|
||||
|
@ -2608,6 +2610,8 @@ class MasterView(View):
|
|||
if not self.can_edit_help():
|
||||
raise self.forbidden()
|
||||
|
||||
# nb. self.Session may differ, so use tailbone.db.Session
|
||||
session = Session()
|
||||
model = self.model
|
||||
route_prefix = self.get_route_prefix()
|
||||
schema = colander.Schema()
|
||||
|
@ -2625,13 +2629,12 @@ class MasterView(View):
|
|||
if not form.validate():
|
||||
return {'error': "Form did not validate"}
|
||||
|
||||
# nb. self.Session may differ, so use tailbone.db.Session
|
||||
info = Session.query(model.TailbonePageHelp)\
|
||||
info = session.query(model.TailbonePageHelp)\
|
||||
.filter(model.TailbonePageHelp.route_prefix == route_prefix)\
|
||||
.first()
|
||||
if not info:
|
||||
info = model.TailbonePageHelp(route_prefix=route_prefix)
|
||||
Session.add(info)
|
||||
session.add(info)
|
||||
|
||||
info.help_url = form.validated['help_url']
|
||||
info.markdown_text = form.validated['markdown_text']
|
||||
|
@ -2641,6 +2644,8 @@ class MasterView(View):
|
|||
if not self.can_edit_help():
|
||||
raise self.forbidden()
|
||||
|
||||
# nb. self.Session may differ, so use tailbone.db.Session
|
||||
session = Session()
|
||||
model = self.model
|
||||
route_prefix = self.get_route_prefix()
|
||||
schema = colander.Schema()
|
||||
|
@ -2657,15 +2662,14 @@ class MasterView(View):
|
|||
if not form.validate():
|
||||
return {'error': "Form did not validate"}
|
||||
|
||||
# nb. self.Session may differ, so use tailbone.db.Session
|
||||
info = Session.query(model.TailboneFieldInfo)\
|
||||
info = session.query(model.TailboneFieldInfo)\
|
||||
.filter(model.TailboneFieldInfo.route_prefix == route_prefix)\
|
||||
.filter(model.TailboneFieldInfo.field_name == form.validated['field_name'])\
|
||||
.first()
|
||||
if not info:
|
||||
info = model.TailboneFieldInfo(route_prefix=route_prefix,
|
||||
field_name=form.validated['field_name'])
|
||||
Session.add(info)
|
||||
session.add(info)
|
||||
|
||||
info.markdown_text = form.validated['markdown_text']
|
||||
return {'ok': True}
|
||||
|
|
|
@ -44,6 +44,7 @@ class PersonView(wutta.PersonView):
|
|||
"""
|
||||
model_class = Person
|
||||
Session = Session
|
||||
sort_defaults = 'display_name'
|
||||
|
||||
labels = {
|
||||
'display_name': "Full Name",
|
||||
|
@ -73,13 +74,6 @@ class PersonView(wutta.PersonView):
|
|||
# CRUD methods
|
||||
##############################
|
||||
|
||||
def get_query(self, session=None):
|
||||
""" """
|
||||
model = self.app.model
|
||||
session = session or self.Session()
|
||||
return session.query(model.Person)\
|
||||
.order_by(model.Person.display_name)
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from sqlalchemy import orm
|
||||
|
||||
from tailbone.grids import core as mod
|
||||
from tests.util import WebTestCase
|
||||
|
@ -27,6 +29,16 @@ class TestGrid(WebTestCase):
|
|||
grid = self.make_grid(component='blarg')
|
||||
self.assertEqual(grid.vue_tagname, 'blarg')
|
||||
|
||||
# default_sortkey, default_sortdir
|
||||
grid = self.make_grid()
|
||||
self.assertEqual(grid.sort_defaults, [])
|
||||
grid = self.make_grid(default_sortkey='name')
|
||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
|
||||
grid = self.make_grid(default_sortdir='desc')
|
||||
self.assertEqual(grid.sort_defaults, [])
|
||||
grid = self.make_grid(default_sortkey='name', default_sortdir='desc')
|
||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
|
||||
|
||||
# pageable
|
||||
grid = self.make_grid()
|
||||
self.assertFalse(grid.paginated)
|
||||
|
@ -159,6 +171,27 @@ class TestGrid(WebTestCase):
|
|||
grid.set_action_urls(setting, setting, 0)
|
||||
self.assertEqual(setting['_action_url_view'], '/blarg')
|
||||
|
||||
def test_default_sortkey(self):
|
||||
grid = self.make_grid()
|
||||
self.assertEqual(grid.sort_defaults, [])
|
||||
self.assertIsNone(grid.default_sortkey)
|
||||
grid.default_sortkey = 'name'
|
||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
|
||||
self.assertEqual(grid.default_sortkey, 'name')
|
||||
grid.default_sortkey = 'value'
|
||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])
|
||||
self.assertEqual(grid.default_sortkey, 'value')
|
||||
|
||||
def test_default_sortdir(self):
|
||||
grid = self.make_grid()
|
||||
self.assertEqual(grid.sort_defaults, [])
|
||||
self.assertIsNone(grid.default_sortdir)
|
||||
self.assertRaises(ValueError, setattr, grid, 'default_sortdir', 'asc')
|
||||
grid.sort_defaults = [mod.SortInfo('name', 'asc')]
|
||||
grid.default_sortdir = 'desc'
|
||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
|
||||
self.assertEqual(grid.default_sortdir, 'desc')
|
||||
|
||||
def test_pageable(self):
|
||||
grid = self.make_grid()
|
||||
self.assertFalse(grid.paginated)
|
||||
|
@ -219,6 +252,212 @@ class TestGrid(WebTestCase):
|
|||
size = grid.get_pagesize()
|
||||
self.assertEqual(size, 15)
|
||||
|
||||
def test_set_sorter(self):
|
||||
model = self.app.model
|
||||
grid = self.make_grid(model_class=model.Setting,
|
||||
sortable=True, sort_on_backend=True)
|
||||
|
||||
# passing None will remove sorter
|
||||
self.assertIn('name', grid.sorters)
|
||||
grid.set_sorter('name', None)
|
||||
self.assertNotIn('name', grid.sorters)
|
||||
|
||||
# can recreate sorter with just column name
|
||||
grid.set_sorter('name')
|
||||
self.assertIn('name', grid.sorters)
|
||||
grid.remove_sorter('name')
|
||||
self.assertNotIn('name', grid.sorters)
|
||||
grid.set_sorter('name', 'name')
|
||||
self.assertIn('name', grid.sorters)
|
||||
|
||||
# can recreate sorter with model property
|
||||
grid.remove_sorter('name')
|
||||
self.assertNotIn('name', grid.sorters)
|
||||
grid.set_sorter('name', model.Setting.name)
|
||||
self.assertIn('name', grid.sorters)
|
||||
|
||||
# extra kwargs are ignored
|
||||
grid.remove_sorter('name')
|
||||
self.assertNotIn('name', grid.sorters)
|
||||
grid.set_sorter('name', model.Setting.name, foo='bar')
|
||||
self.assertIn('name', grid.sorters)
|
||||
|
||||
# passing multiple args will invoke make_filter() directly
|
||||
grid.remove_sorter('name')
|
||||
self.assertNotIn('name', grid.sorters)
|
||||
with patch.object(grid, 'make_sorter') as make_sorter:
|
||||
make_sorter.return_value = 42
|
||||
grid.set_sorter('name', 'foo', 'bar')
|
||||
make_sorter.assert_called_once_with('foo', 'bar')
|
||||
self.assertEqual(grid.sorters['name'], 42)
|
||||
|
||||
def test_make_simple_sorter(self):
|
||||
model = self.app.model
|
||||
grid = self.make_grid(model_class=model.Setting,
|
||||
sortable=True, sort_on_backend=True)
|
||||
|
||||
# delegates to grid.make_sorter()
|
||||
with patch.object(grid, 'make_sorter') as make_sorter:
|
||||
make_sorter.return_value = 42
|
||||
sorter = grid.make_simple_sorter('name', foldcase=True)
|
||||
make_sorter.assert_called_once_with('name', foldcase=True)
|
||||
self.assertEqual(sorter, 42)
|
||||
|
||||
def test_load_settings(self):
|
||||
model = self.app.model
|
||||
|
||||
# nb. first use a paging grid
|
||||
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True,
|
||||
pagesize=20, page=1)
|
||||
|
||||
# settings are loaded, applied, saved
|
||||
self.assertEqual(grid.page, 1)
|
||||
self.assertNotIn('grid.foo.page', self.request.session)
|
||||
self.request.GET = {'pagesize': '10', 'page': '2'}
|
||||
grid.load_settings()
|
||||
self.assertEqual(grid.page, 2)
|
||||
self.assertEqual(self.request.session['grid.foo.page'], 2)
|
||||
|
||||
# can skip the saving step
|
||||
self.request.GET = {'pagesize': '10', 'page': '3'}
|
||||
grid.load_settings(store=False)
|
||||
self.assertEqual(grid.page, 3)
|
||||
self.assertEqual(self.request.session['grid.foo.page'], 2)
|
||||
|
||||
# no error for non-paginated grid
|
||||
grid = self.make_grid(key='foo', paginated=False)
|
||||
grid.load_settings()
|
||||
self.assertFalse(grid.paginated)
|
||||
|
||||
# nb. next use a sorting grid
|
||||
grid = self.make_grid(key='settings', model_class=model.Setting,
|
||||
sortable=True, sort_on_backend=True)
|
||||
|
||||
# settings are loaded, applied, saved
|
||||
self.assertEqual(grid.sort_defaults, [])
|
||||
self.assertFalse(hasattr(grid, 'active_sorters'))
|
||||
self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'}
|
||||
grid.load_settings()
|
||||
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
|
||||
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')
|
||||
|
||||
# can skip the saving step
|
||||
self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'}
|
||||
grid.load_settings(store=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')
|
||||
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
|
||||
|
||||
# no error for non-sortable grid
|
||||
grid = self.make_grid(key='foo', sortable=False)
|
||||
grid.load_settings()
|
||||
self.assertFalse(grid.sortable)
|
||||
|
||||
# with sort defaults
|
||||
grid = self.make_grid(model_class=model.Setting, sortable=True,
|
||||
sort_on_backend=True, sort_defaults='name')
|
||||
self.assertFalse(hasattr(grid, 'active_sorters'))
|
||||
grid.load_settings()
|
||||
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
|
||||
|
||||
# with multi-column sort defaults
|
||||
grid = self.make_grid(model_class=model.Setting, sortable=True,
|
||||
sort_on_backend=True)
|
||||
grid.sort_defaults = [
|
||||
mod.SortInfo('name', 'asc'),
|
||||
mod.SortInfo('value', 'desc'),
|
||||
]
|
||||
self.assertFalse(hasattr(grid, 'active_sorters'))
|
||||
grid.load_settings()
|
||||
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
|
||||
|
||||
# load settings from session when nothing is in request
|
||||
self.request.GET = {}
|
||||
self.request.session.invalidate()
|
||||
self.assertNotIn('grid.settings.sorters.length', self.request.session)
|
||||
self.request.session['grid.settings.sorters.length'] = 1
|
||||
self.request.session['grid.settings.sorters.1.key'] = 'name'
|
||||
self.request.session['grid.settings.sorters.1.dir'] = 'desc'
|
||||
grid = self.make_grid(key='settings', model_class=model.Setting,
|
||||
sortable=True, sort_on_backend=True,
|
||||
paginated=True, paginate_on_backend=True)
|
||||
self.assertFalse(hasattr(grid, 'active_sorters'))
|
||||
grid.load_settings()
|
||||
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
|
||||
|
||||
def test_sort_data(self):
|
||||
model = self.app.model
|
||||
sample_data = [
|
||||
{'name': 'foo1', 'value': 'ONE'},
|
||||
{'name': 'foo2', 'value': 'two'},
|
||||
{'name': 'foo3', 'value': 'three'},
|
||||
{'name': 'foo4', 'value': 'four'},
|
||||
{'name': 'foo5', 'value': 'five'},
|
||||
{'name': 'foo6', 'value': 'six'},
|
||||
{'name': 'foo7', 'value': 'seven'},
|
||||
{'name': 'foo8', 'value': 'eight'},
|
||||
{'name': 'foo9', 'value': 'nine'},
|
||||
]
|
||||
for setting in sample_data:
|
||||
self.app.save_setting(self.session, setting['name'], setting['value'])
|
||||
self.session.commit()
|
||||
sample_query = self.session.query(model.Setting)
|
||||
|
||||
grid = self.make_grid(model_class=model.Setting,
|
||||
sortable=True, sort_on_backend=True,
|
||||
sort_defaults=('name', 'desc'))
|
||||
grid.load_settings()
|
||||
|
||||
# can sort a simple list of data
|
||||
sorted_data = grid.sort_data(sample_data)
|
||||
self.assertIsInstance(sorted_data, list)
|
||||
self.assertEqual(len(sorted_data), 9)
|
||||
self.assertEqual(sorted_data[0]['name'], 'foo9')
|
||||
self.assertEqual(sorted_data[-1]['name'], 'foo1')
|
||||
|
||||
# can also sort a data query
|
||||
sorted_query = grid.sort_data(sample_query)
|
||||
self.assertIsInstance(sorted_query, orm.Query)
|
||||
sorted_data = sorted_query.all()
|
||||
self.assertEqual(len(sorted_data), 9)
|
||||
self.assertEqual(sorted_data[0]['name'], 'foo9')
|
||||
self.assertEqual(sorted_data[-1]['name'], 'foo1')
|
||||
|
||||
# cannot sort data if sorter missing in overrides
|
||||
sorted_data = grid.sort_data(sample_data, sorters=[])
|
||||
# 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')
|
||||
|
||||
# error if mult-column sort attempted
|
||||
self.assertRaises(NotImplementedError, grid.sort_data, sample_data, sorters=[
|
||||
{'key': 'name', 'dir': 'desc'},
|
||||
{'key': 'value', 'dir': 'asc'},
|
||||
])
|
||||
|
||||
# cannot sort data if sortfunc is missing for column
|
||||
grid.remove_sorter('name')
|
||||
sorted_data = grid.sort_data(sample_data)
|
||||
# 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')
|
||||
|
||||
# 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):
|
||||
model = self.app.model
|
||||
|
||||
|
@ -249,11 +488,13 @@ class TestGrid(WebTestCase):
|
|||
model = self.app.model
|
||||
|
||||
# sanity check
|
||||
grid = self.make_grid('settings', model_class=model.Setting)
|
||||
grid = self.make_grid('settings', model_class=model.Setting, sortable=True)
|
||||
columns = grid.get_vue_columns()
|
||||
self.assertEqual(len(columns), 2)
|
||||
self.assertEqual(columns[0]['field'], 'name')
|
||||
self.assertTrue(columns[0]['sortable'])
|
||||
self.assertEqual(columns[1]['field'], 'value')
|
||||
self.assertTrue(columns[1]['sortable'])
|
||||
|
||||
def test_get_vue_data(self):
|
||||
model = self.app.model
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from tailbone.views import master as mod
|
||||
from wuttaweb.grids import GridAction
|
||||
|
@ -33,3 +33,34 @@ class TestMasterView(WebTestCase):
|
|||
view = self.make_view()
|
||||
action = view.make_action('view')
|
||||
self.assertIsInstance(action, GridAction)
|
||||
|
||||
def test_index(self):
|
||||
self.pyramid_config.include('tailbone.views.common')
|
||||
self.pyramid_config.include('tailbone.views.auth')
|
||||
model = self.app.model
|
||||
|
||||
# mimic view for /settings
|
||||
with patch.object(mod, 'Session', return_value=self.session):
|
||||
with patch.multiple(mod.MasterView, create=True,
|
||||
model_class=model.Setting,
|
||||
Session=MagicMock(return_value=self.session),
|
||||
get_index_url=MagicMock(return_value='/settings/'),
|
||||
get_help_url=MagicMock(return_value=None)):
|
||||
|
||||
# basic
|
||||
view = self.make_view()
|
||||
response = view.index()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# then again with data, to include view action url
|
||||
data = [{'name': 'foo', 'value': 'bar'}]
|
||||
with patch.object(view, 'get_data', return_value=data):
|
||||
response = view.index()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content_type, 'text/html')
|
||||
|
||||
# then once more as 'partial' - aka. data only
|
||||
self.request.GET = {'partial': '1'}
|
||||
response = view.index()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content_type, 'application/json')
|
||||
|
|
Loading…
Reference in a new issue