feat: move single-column grid sorting logic to wuttaweb

This commit is contained in:
Lance Edgar 2024-08-18 14:05:52 -05:00
parent c95e42bf82
commit ec36df4a34
7 changed files with 475 additions and 200 deletions

View file

@ -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']

View file

@ -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:
self.remove_sorter(key)
""" """
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,34 +1373,36 @@ 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 sorter callable
sortfunc = self.sorters.get(sortkey)
if not sortfunc:
return data
# Cannot sort unless we have a sort function.
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)
# apply joins needed for this sorter
if sortkey in self.joiners and sortkey not in self.joined:
data = self.joiners[sortkey](data)
self.joined.add(sortkey)
return sortfunc(data, sortdir)
# 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

View file

@ -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,17 +475,18 @@
},
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.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
% if grid.paginated:
params.pagesize = this.perPage
params.page = this.currentPage
% 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()
},

View file

@ -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}

View file

@ -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)

View file

@ -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

View file

@ -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')