2
0
Fork 0

Compare commits

..

4 commits

Author SHA1 Message Date
Lance Edgar 91e10274ea bump: version 0.9.0 → 0.10.0 2024-08-18 19:58:13 -05:00
Lance Edgar 8d6f4ad368 feat: add multi-column sorting (frontend or backend) for grids 2024-08-18 19:52:11 -05:00
Lance Edgar 58f7a862a2 feat; add single-column sorting (frontend or backend) for grids 2024-08-18 14:05:10 -05:00
Lance Edgar f21efbab9f fix: improve grid display when data is empty 2024-08-17 11:48:06 -05:00
11 changed files with 1568 additions and 108 deletions

View file

@ -5,6 +5,16 @@ All notable changes to wuttaweb will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.10.0 (2024-08-18)
### Feat
- add multi-column sorting (frontend or backend) for grids
### Fix
- improve grid display when data is empty
## v0.9.0 (2024-08-16)
### Feat

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaWeb"
version = "0.9.0"
version = "0.10.0"
description = "Web App for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@ -32,6 +32,7 @@ requires-python = ">= 3.8"
dependencies = [
"ColanderAlchemy",
"paginate",
"paginate_sqlalchemy",
"pyramid>=2",
"pyramid_beaker",
"pyramid_deform",

View file

@ -26,6 +26,8 @@ Grids Library
The ``wuttaweb.grids`` namespace contains the following:
* :class:`~wuttaweb.grids.base.Grid`
* :class:`~wuttaweb.grids.base.GridAction`
* :class:`~wuttaweb.grids.base.SortInfo`
"""
from .base import Grid, GridAction
from .base import Grid, GridAction, SortInfo

View file

@ -27,10 +27,13 @@ Base grid classes
import functools
import json
import logging
from collections import namedtuple
import sqlalchemy as sa
from sqlalchemy import orm
import paginate
from paginate_sqlalchemy import SqlalchemyOrmPage
from pyramid.renderers import render
from webhelpers2.html import HTML
@ -41,6 +44,13 @@ from wuttaweb.util import FieldList, get_model_fields, make_json_safe
log = logging.getLogger(__name__)
SortInfo = namedtuple('SortInfo', ['sortkey', 'sortdir'])
SortInfo.__doc__ = """
Named tuple to track sorting info.
Elements of :attr:`~Grid.sort_defaults` will be of this type.
"""
class Grid:
"""
Base class for all grids.
@ -116,6 +126,117 @@ class Grid:
See also :meth:`set_link()` and :meth:`is_linked()`.
.. attribute:: sortable
Boolean indicating whether *any* column sorting is allowed for
the grid. Default is ``False``.
See also :attr:`sort_multiple` and :attr:`sort_on_backend`.
.. attribute:: sort_multiple
Boolean indicating whether "multi-column" sorting is allowed.
Default is ``True``; if this is ``False`` then only one column
may be sorted at a time.
Only relevant if :attr:`sortable` is true, but applies to both
frontend and backend sorting.
.. warning::
This feature is limited by frontend JS capabilities,
regardless of :attr:`sort_on_backend` value (i.e. for both
frontend and backend sorting).
In particular, if the app theme templates use Vue 2 + Buefy,
then multi-column sorting should work.
But not so with Vue 3 + Oruga, *yet* - see also the `open
issue <https://github.com/oruga-ui/oruga/issues/962>`_
regarding that. For now this flag is simply ignored for
Vue 3 + Oruga templates.
Additionally, even with Vue 2 + Buefy this flag can only
allow the user to *request* a multi-column sort. Whereas
the "default sort" in the Vue component can only ever be
single-column, regardless of :attr:`sort_defaults`.
.. attribute:: sort_on_backend
Boolean indicating whether the grid data should be sorted on the
backend. Default is ``True``.
If ``False``, the client-side Vue component will handle the
sorting.
Only relevant if :attr:`sortable` is also true.
.. attribute:: sorters
Dict of functions to use for backend sorting.
Only relevant if both :attr:`sortable` and
:attr:`sort_on_backend` are true.
See also :meth:`set_sorter()`, :attr:`sort_defaults` and
:attr:`active_sorters`.
.. attribute:: sort_defaults
List of options to be used for default sorting, until the user
requests a different sorting method.
This list usually contains either zero or one elements. (More
are allowed if :attr:`sort_multiple` is true, but see note
below.) Each list element is a :class:`SortInfo` tuple and
must correspond to an entry in :attr:`sorters`.
Used with both frontend and backend sorting.
See also :meth:`set_sort_defaults()` and
:attr:`active_sorters`.
.. warning::
While the grid logic is built to handle multi-column
sorting, this feature is limited by frontend JS
capabilities.
Even if ``sort_defaults`` contains multiple entries
(i.e. for multi-column sorting to be used "by default" for
the grid), only the *first* entry (i.e. single-column
sorting) will actually be used as the default for the Vue
component.
See also :attr:`sort_multiple` for more details.
.. attribute:: active_sorters
List of sorters currently in effect for the grid; used by
:meth:`sort_data()`.
Whereas :attr:`sorters` defines all "available" sorters, and
:attr:`sort_defaults` defines the "default" sorters,
``active_sorters`` defines the "current/effective" sorters.
This attribute is set by :meth:`load_settings()`; until that is
called it will not exist.
This is conceptually a "subset" of :attr:`sorters` although a
different format is used here::
grid.active_sorters = [
{'key': 'name', 'dir': 'asc'},
{'key': 'id', 'dir': 'asc'},
]
The above is for example only; there is usually no reason to
set this attribute directly.
This list may contain multiple elements only if
:attr:`sort_multiple` is true. Otherewise it should always
have either zero or one element.
.. attribute:: paginated
Boolean indicating whether the grid data should be paginated
@ -175,6 +296,11 @@ class Grid:
renderers={},
actions=[],
linked_columns=[],
sortable=False,
sort_multiple=True,
sort_on_backend=True,
sorters=None,
sort_defaults=None,
paginated=False,
paginate_on_backend=True,
pagesize_options=None,
@ -196,6 +322,22 @@ class Grid:
self.set_columns(columns or self.get_columns())
# sorting
self.sortable = sortable
self.sort_multiple = sort_multiple
if self.sort_multiple and self.request.use_oruga:
log.warning("grid.sort_multiple is not implemented for Oruga-based templates")
self.sort_multiple = False
self.sort_on_backend = sort_on_backend
if sorters is not None:
self.sorters = sorters
elif self.sortable and self.sort_on_backend:
self.sorters = self.make_backend_sorters()
else:
self.sorters = {}
self.set_sort_defaults(sort_defaults or [])
# paging
self.paginated = paginated
self.paginate_on_backend = paginate_on_backend
self.pagesize_options = pagesize_options or self.get_pagesize_options()
@ -402,6 +544,302 @@ class Grid:
return True
return False
##############################
# sorting methods
##############################
def make_backend_sorters(self, sorters=None):
"""
Make backend sorters for all columns in the grid.
This is called by the constructor, if both :attr:`sortable`
and :attr:`sort_on_backend` are true.
For each column in the grid, this checks the provided
``sorters`` and if the column is not yet in there, will call
:meth:`make_sorter()` to add it.
.. note::
This only works if grid has a :attr:`model_class`. If not,
this method just returns the initial sorters (or empty
dict).
:param sorters: Optional dict of initial sorters. Any
existing sorters will be left intact, not replaced.
:returns: Final dict of all sorters. Includes any from the
initial ``sorters`` param as well as any which were
created.
"""
sorters = sorters or {}
if self.model_class:
for key in self.columns:
if key in sorters:
continue
prop = getattr(self.model_class, key, None)
if prop and isinstance(prop.property, orm.ColumnProperty):
sorters[prop.key] = self.make_sorter(prop)
return sorters
def make_sorter(self, columninfo, keyfunc=None, foldcase=True):
"""
Returns a function suitable for use as a backend sorter on the
given column.
Code usually does not need to call this directly. See also
:meth:`set_sorter()`, which calls this method automatically.
:param columninfo: Can be either a model property (see below),
or a column name.
:param keyfunc: Optional function to use as the "sort key
getter" callable, if the sorter is manual (as opposed to
SQLAlchemy query). More on this below. If not specified,
a default function is used.
:param foldcase: If the sorter is manual (not SQLAlchemy), and
the column data is of text type, this may be used to
automatically "fold case" for the sorting. Defaults to
``True`` since this behavior is presumably expected, but
may be disabled if needed.
The term "model property" is a bit technical, an example
should help to clarify::
model = self.app.model
grid = Grid(self.request, model_class=model.Person)
# explicit property
sorter = grid.make_sorter(model.Person.full_name)
# property name works if grid has model class
sorter = grid.make_sorter('full_name')
# nb. this will *not* work
person = model.Person(full_name="John Doe")
sorter = grid.make_sorter(person.full_name)
The ``keyfunc`` param allows you to override the way sort keys
are obtained from data records (this only applies for a
"manual" sort, where data is a list and not a SQLAlchemy
query)::
data = [
{'foo': 1},
{'bar': 2},
]
# nb. no model_class, just as an example
grid = Grid(self.request, columns=['foo', 'bar'], data=data)
def getkey(obj):
if obj.get('foo')
return obj['foo']
if obj.get('bar'):
return obj['bar']
return ''
# nb. sortfunc will ostensibly sort by 'foo' column, but in
# practice it is sorted per value from getkey() above
sortfunc = grid.make_sorter('foo', keyfunc=getkey)
sorted_data = sortfunc(data, 'asc')
:returns: A function suitable for backend sorting. This
function will behave differently when it is given a
SQLAlchemy query vs. a "list" of data. In either case it
will return the sorted result.
This function may be called as shown above. It expects 2
args: ``(data, direction)``
"""
model_class = None
model_property = None
if isinstance(columninfo, str):
key = columninfo
model_class = self.model_class
model_property = getattr(self.model_class, key, None)
else:
model_property = columninfo
model_class = model_property.class_
key = model_property.key
def sorter(data, direction):
# query is sorted with order_by()
if isinstance(data, orm.Query):
if not model_property:
raise TypeError(f"grid sorter for '{key}' does not map to a model property")
query = data
return query.order_by(getattr(model_property, direction)())
# other data is sorted manually. first step is to
# identify the function used to produce a sort key for
# each record
kfunc = keyfunc
if not kfunc:
if model_property:
# TODO: may need this for String etc. as well?
if isinstance(model_property.type, sa.Text):
if foldcase:
kfunc = lambda obj: (obj[key] or '').lower()
else:
kfunc = lambda obj: obj[key] or ''
if not kfunc:
# nb. sorting with this can raise error if data
# contains varying types, e.g. str and None
kfunc = lambda obj: obj[key]
# then sort the data and return
return sorted(data, key=kfunc, reverse=direction == 'desc')
# TODO: this should be improved; is needed in tailbone for
# multi-column sorting with sqlalchemy queries
if model_property:
sorter._class = model_class
sorter._column = model_property
return sorter
def set_sorter(self, key, sortinfo=None):
"""
Set/override the backend sorter for a column.
Only relevant if both :attr:`sortable` and
:attr:`sort_on_backend` are true.
:param key: Name of column.
:param sortinfo: Can be either a sorter callable, or else a
model property (see below).
If ``sortinfo`` is a callable, it will be used as-is for the
backend sorter.
Otherwise :meth:`make_sorter()` will be called to obtain the
backend sorter. The ``sortinfo`` will be passed along to that
call; if it is empty then ``key`` will be used instead.
A backend sorter callable must accept ``(data, direction)``
args and return the sorted data/query, for example::
model = self.app.model
grid = Grid(self.request, model_class=model.Person)
def sort_full_name(query, direction):
sortspec = getattr(model.Person.full_name, direction)
return query.order_by(sortspec())
grid.set_sorter('full_name', sort_full_name)
See also :meth:`remove_sorter()` and :meth:`is_sortable()`.
Backend sorters are tracked via :attr:`sorters`.
"""
sorter = None
if sortinfo and callable(sortinfo):
sorter = sortinfo
else:
sorter = self.make_sorter(sortinfo or key)
self.sorters[key] = sorter
def remove_sorter(self, key):
"""
Remove the backend sorter for a column.
See also :meth:`set_sorter()`.
"""
self.sorters.pop(key, None)
def set_sort_defaults(self, *args):
"""
Set the default sorting method for the grid. This sorting is
used unless/until the user requests a different sorting
method.
``args`` for this method are interpreted as follows:
If 2 args are received, they should be for ``sortkey`` and
``sortdir``; for instance::
grid.set_sort_defaults('name', 'asc')
If just one 2-tuple arg is received, it is handled similarly::
grid.set_sort_defaults(('name', 'asc'))
If just one string arg is received, the default ``sortdir`` is
assumed::
grid.set_sort_defaults('name') # assumes 'asc'
Otherwise there should be just one list arg, elements of
which are each 2-tuples of ``(sortkey, sortdir)`` info::
grid.set_sort_defaults([('name', 'asc'),
('value', 'desc')])
.. note::
Note that :attr:`sort_multiple` determines whether the grid
is actually allowed to have multiple sort defaults. The
defaults requested by the method call may be pruned if
necessary to accommodate that.
Default sorting info is tracked via :attr:`sort_defaults`.
"""
# convert args to sort defaults
sort_defaults = []
if len(args) == 1:
if isinstance(args[0], str):
sort_defaults = [SortInfo(args[0], 'asc')]
elif isinstance(args[0], tuple) and len(args[0]) == 2:
sort_defaults = [SortInfo(*args[0])]
elif isinstance(args[0], list):
sort_defaults = [SortInfo(*tup) for tup in args[0]]
else:
raise ValueError("for just one positional arg, must pass string, 2-tuple or list")
elif len(args) == 2:
sort_defaults = [SortInfo(*args)]
else:
raise ValueError("must pass just one or two positional args")
# prune if multi-column requested but not supported
if len(sort_defaults) > 1 and not self.sort_multiple:
log.warning("multi-column sorting is not enabled for the instance; "
"list will be pruned to first element for '%s' grid: %s",
self.key, sort_defaults)
sort_defaults = [sort_defaults[0]]
self.sort_defaults = sort_defaults
def is_sortable(self, key):
"""
Returns boolean indicating if a given column should allow
sorting.
If :attr:`sortable` is false, this always returns ``False``.
For frontend sorting (i.e. :attr:`sort_on_backend` is false),
this always returns ``True``.
For backend sorting, may return true or false depending on
whether the column is listed in :attr:`sorters`.
:param key: Column key as string.
See also :meth:`set_sorter()`.
"""
if not self.sortable:
return False
if self.sort_on_backend:
return key in self.sorters
return True
##############################
# paging methods
##############################
@ -460,21 +898,19 @@ class Grid:
# configuration methods
##############################
def load_settings(self, store=True):
def load_settings(self, persist=True):
"""
Load all effective settings for the grid, from the following
places:
Load all effective settings for the grid.
* request params
* user session
The first value found for a given setting will be applied to
the grid.
If the request GET params (query string) contains grid
settings, they are used; otherwise the settings are loaded
from user session.
.. note::
As of now, "pagination" settings are the only type
supported by this logic. Filter/sort coming soon...
As of now, "sorting" and "pagination" settings are the only
type supported by this logic. Settings for "filtering"
coming soon...
The overall logic for this method is as follows:
@ -483,45 +919,149 @@ class Grid:
* optionally save settings to user session
Saving the settings to user session will allow the grid to
"remember" its current settings when user refreshes the page.
remember its current settings when user refreshes the page, or
navigates away then comes back. Therefore normally, settings
are saved each time they are loaded. Note that such settings
are wiped upon user logout.
:param store: Flag indicating whether the collected settings
should then be saved to the user session.
:param persist: Whether the collected settings should be saved
to the user session.
"""
# initial default settings
settings = {}
if self.sortable:
if self.sort_defaults:
# nb. as of writing neither Buefy nor Oruga support a
# multi-column *default* sort; so just use first sorter
sortinfo = self.sort_defaults[0]
settings['sorters.length'] = 1
settings['sorters.1.key'] = sortinfo.sortkey
settings['sorters.1.dir'] = sortinfo.sortdir
else:
settings['sorters.length'] = 0
if self.paginated and self.paginate_on_backend:
settings['pagesize'] = self.pagesize
settings['page'] = self.page
# grab settings from request and/or user session
if self.paginated and self.paginate_on_backend:
# update settings dict based on what we find in the request
# and/or user session. always prioritize the former.
if self.request_has_settings('sort'):
self.update_sort_settings(settings, src='request')
self.update_page_settings(settings)
elif self.request_has_settings('page'):
self.update_sort_settings(settings, src='session')
self.update_page_settings(settings)
else:
# no settings were found in request or user session, so
# nothing needs to be saved
store = False
# nothing found in request, so nothing new to save
persist = False
# maybe store settings in user session, for next time
if store:
self.persist_settings(settings)
# but still should load whatever is in user session
self.update_sort_settings(settings, src='session')
self.update_page_settings(settings)
# update ourself to reflect settings
# maybe save settings in user session, for next time
if persist:
self.persist_settings(settings, dest='session')
# update ourself to reflect settings dict..
# sorting
if self.sortable:
# nb. doing this for frontend sorting also
self.active_sorters = []
for i in range(1, settings['sorters.length'] + 1):
self.active_sorters.append({
'key': settings[f'sorters.{i}.key'],
'dir': settings[f'sorters.{i}.dir'],
})
# TODO: i thought this was needed, but now idk?
# # nb. when showing full index page (i.e. not partial)
# # this implies we must set the default sorter for Vue
# # component, and only single-column is allowed there.
# if not self.request.GET.get('partial'):
# break
# paging
if self.paginated and self.paginate_on_backend:
self.pagesize = settings['pagesize']
self.page = settings['page']
def request_has_settings(self):
def request_has_settings(self, typ):
""" """
for key in ['pagesize', 'page']:
if key in self.request.GET:
if typ == 'sort':
if 'sort1key' in self.request.GET:
return True
elif typ == 'page':
for key in ['pagesize', 'page']:
if key in self.request.GET:
return True
return False
def get_setting(self, settings, key, src='session', default=None,
normalize=lambda v: v):
""" """
if src == 'request':
value = self.request.GET.get(key)
if value is not None:
try:
return normalize(value)
except ValueError:
pass
elif src == 'session':
value = self.request.session.get(f'grid.{self.key}.{key}')
if value is not None:
return normalize(value)
# if src had nothing, try default/existing settings
value = settings.get(key)
if value is not None:
return normalize(value)
# okay then, default it is
return default
def update_sort_settings(self, settings, src=None):
""" """
if not (self.sortable and self.sort_on_backend):
return
if src == 'request':
i = 1
while True:
skey = f'sort{i}key'
if skey in self.request.GET:
settings[f'sorters.{i}.key'] = self.get_setting(settings, skey,
src='request')
settings[f'sorters.{i}.dir'] = self.get_setting(settings, f'sort{i}dir',
src='request',
default='asc')
else:
break
i += 1
settings['sorters.length'] = i - 1
elif src == 'session':
settings['sorters.length'] = self.get_setting(settings, 'sorters.length',
src='session', normalize=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(settings, skey, src='session')
def update_page_settings(self, settings):
""" """
if not (self.paginated and self.paginate_on_backend):
return
# update the settings dict from request and/or user session
# pagesize
@ -544,17 +1084,42 @@ class Grid:
if page is not None:
settings['page'] = int(page)
def persist_settings(self, settings):
def persist_settings(self, settings, dest=None):
""" """
model = self.app.model
session = Session()
if dest not in ('session',):
raise ValueError(f"invalid dest identifier: {dest}")
# func to save a setting value to user session
def persist(key, value=lambda k: settings.get(k)):
assert dest == 'session'
skey = f'grid.{self.key}.{key}'
self.request.session[skey] = value(key)
# sort settings
if self.sortable:
# first must clear all sort settings from dest. this is
# because number of sort settings will vary, so we delete
# all and then write all
if dest == 'session':
# remove sort settings from user session
prefix = f'grid.{self.key}.sorters.'
for key in list(self.request.session):
if key.startswith(prefix):
del self.request.session[key]
# now save sort settings to dest
if 'sorters.length' in settings:
persist('sorters.length')
for i in range(1, settings['sorters.length'] + 1):
persist(f'sorters.{i}.key')
persist(f'sorters.{i}.dir')
# pagination settings
if self.paginated and self.paginate_on_backend:
# save to dest
persist('pagesize')
persist('page')
@ -579,12 +1144,50 @@ class Grid:
"""
data = self.data or []
if self.sortable and self.sort_on_backend:
data = self.sort_data(data)
if self.paginated and self.paginate_on_backend:
self.pager = self.paginate_data(data)
data = self.pager
return data
def sort_data(self, data, sorters=None):
"""
Sort the given data and return the result. This is called by
:meth:`get_visible_data()`.
:param sorters: Optional list of sorters to use. If not
specified, the grid's :attr:`active_sorters` are used.
"""
if sorters is None:
sorters = self.active_sorters
if not sorters:
return data
# nb. when data is a query, we want to apply sorters in the
# requested order, so the final query has order_by() in the
# correct "as-is" sequence. however when data is a list we
# must do the opposite, applying in the reverse order, so the
# final list has the most "important" sort(s) applied last.
if not isinstance(data, orm.Query):
sorters = reversed(sorters)
for sorter in sorters:
sortkey = sorter['key']
sortdir = sorter['dir']
# cannot sort unless we have a sorter callable
sortfunc = self.sorters.get(sortkey)
if not sortfunc:
return data
# invoke the sorter
data = sortfunc(data, sortdir)
return data
def paginate_data(self, data):
"""
Apply pagination to the given data set, based on grid settings.
@ -594,9 +1197,27 @@ class Grid:
This method is called by :meth:`get_visible_data()`.
"""
pager = paginate.Page(data,
items_per_page=self.pagesize,
page=self.page)
if isinstance(data, orm.Query):
pager = SqlalchemyOrmPage(data,
items_per_page=self.pagesize,
page=self.page)
else:
pager = paginate.Page(data,
items_per_page=self.pagesize,
page=self.page)
# pager may have detected that our current page is outside the
# valid range. if so we should update ourself to match
if pager.page != self.page:
self.page = pager.page
key = f'grid.{self.key}.page'
if key in self.request.session:
self.request.session[key] = self.page
# and re-make the pager just to be safe (?)
pager = self.paginate_data(data)
return pager
##############################
@ -674,9 +1295,33 @@ class Grid:
columns.append({
'field': name,
'label': self.get_label(name),
'sortable': self.is_sortable(name),
})
return columns
def get_vue_active_sorters(self):
"""
Returns a list of Vue-compatible column sorter definitions.
The list returned is the same as :attr:`active_sorters`;
however the format used in Vue is different. So this method
just "converts" them to the required format, e.g.::
# active_sorters format
{'key': 'name', 'dir': 'asc'}
# get_vue_active_sorters() format
{'field': 'name', 'order': 'asc'}
:returns: The :attr:`active_sorters` list, converted as
described above.
"""
sorters = []
for sorter in self.active_sorters:
sorters.append({'field': sorter['key'],
'order': sorter['dir']})
return sorters
def get_vue_data(self):
"""
Returns a list of Vue-compatible data records.

View file

@ -3,11 +3,37 @@
<script type="text/x-template" id="${grid.vue_tagname}-template">
<${b}-table :data="data"
:loading="loading"
narrowed
hoverable
icon-pack="fas"
## sorting
% if grid.sortable:
## nb. buefy/oruga only support *one* default sorter
:default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null"
% if grid.sort_on_backend:
backend-sorting
@sort="onSort"
% endif
% if grid.sort_multiple:
% if grid.sort_on_backend:
## TODO: there is a bug (?) which prevents the arrow
## from displaying for simple default single-column sort,
## when multi-column sort is allowed for the table. for
## now we work around that by waiting until mount to
## enable the multi-column support. see also
## https://github.com/buefy/buefy/issues/2584
:sort-multiple="allowMultiSort"
:sort-multiple-data="sortingPriority"
@sorting-priority-removed="sortingPriorityRemoved"
% else:
sort-multiple
% endif
## nb. user must ctrl-click column header for multi-sort
sort-multiple-key="ctrlKey"
% endif
% endif
## paging
% if grid.paginated:
paginated
@ -26,6 +52,7 @@
<${b}-table-column field="${column['field']}"
label="${column['label']}"
v-slot="props"
:sortable="${json.dumps(column.get('sortable', False))|n}"
cell-class="c_${column['field']}">
% if grid.is_linked(column['field']):
<a :href="props.row._action_url_view"
@ -51,11 +78,27 @@
</${b}-table-column>
% endif
<template #empty>
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>
<b-icon
pack="fas"
icon="sad-tear"
size="is-large">
</b-icon>
</p>
<p>Nothing here.</p>
</div>
</section>
</template>
% if grid.paginated:
<template #footer>
<div style="display: flex; justify-content: space-between;">
<div></div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<div v-if="pagerStats.first_item"
style="display: flex; gap: 0.5rem; align-items: center;">
<span>
showing
{{ renderNumber(pagerStats.first_item) }}
@ -91,6 +134,28 @@
data: ${grid.vue_component}CurrentData,
loading: false,
## sorting
% if grid.sortable:
sorters: ${json.dumps(grid.get_vue_active_sorters())|n},
% if grid.sort_multiple:
% if grid.sort_on_backend:
## TODO: there is a bug (?) which prevents the arrow
## from displaying for simple default single-column sort,
## when multi-column sort is allowed for the table. for
## now we work around that by waiting until mount to
## enable the multi-column support. see also
## https://github.com/buefy/buefy/issues/2584
allowMultiSort: false,
## nb. this should be empty when current sort is single-column
% if len(grid.active_sorters) > 1:
sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n},
% else:
sortingPriority: [],
% endif
% endif
% endif
% endif
## paging
% if grid.paginated:
pageSizeOptions: ${json.dumps(grid.pagesize_options)|n},
@ -109,13 +174,14 @@
% if not grid.paginate_on_backend:
pagerStats() {
const data = this.data
let last = this.currentPage * this.perPage
let first = last - this.perPage + 1
if (last > this.data.length) {
last = this.data.length
if (last > data.length) {
last = data.length
}
return {
'item_count': this.data.length,
'item_count': data.length,
'items_per_page': this.perPage,
'page': this.currentPage,
'first_item': first,
@ -125,6 +191,21 @@
% endif
},
% if grid.sortable and grid.sort_multiple and grid.sort_on_backend:
## TODO: there is a bug (?) which prevents the arrow
## from displaying for simple default single-column sort,
## when multi-column sort is allowed for the table. for
## now we work around that by waiting until mount to
## enable the multi-column support. see also
## https://github.com/buefy/buefy/issues/2584
mounted() {
this.allowMultiSort = true
},
% endif
methods: {
renderNumber(value) {
@ -134,21 +215,24 @@
},
getBasicParams() {
return {
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].field
params['sort'+i+'dir'] = this.sorters[i-1].order
}
% endif
return params
},
async fetchData(params, success, failure) {
async fetchData() {
if (params === undefined || params === null) {
params = new URLSearchParams(this.getBasicParams())
} else {
params = new URLSearchParams(params)
}
let params = new URLSearchParams(this.getBasicParams())
if (!params.has('partial')) {
params.append('partial', true)
}
@ -156,8 +240,6 @@
this.loading = true
this.$http.get(`${request.path_url}?${'$'}{params}`).then(response => {
console.log(response)
console.log(response.data)
if (!response.data.error) {
${grid.vue_component}CurrentData = response.data.data
this.data = ${grid.vue_component}CurrentData
@ -165,9 +247,6 @@
this.pagerStats = response.data.pager_stats
% endif
this.loading = false
if (success) {
success()
}
} else {
this.$buefy.toast.open({
message: data.error,
@ -175,9 +254,6 @@
duration: 2000, // 4 seconds
})
this.loading = false
if (failure) {
failure()
}
}
})
.catch((error) => {
@ -186,13 +262,71 @@
this.pagerStats = {}
% endif
this.loading = false
if (failure) {
failure()
}
throw error
})
},
% if grid.sortable and grid.sort_on_backend:
onSort(field, order, event) {
## nb. buefy passes field name; oruga passes field object
% if request.use_oruga:
field = field.field
% endif
% if grid.sort_multiple:
// did user ctrl-click the column header?
if (event.ctrlKey) {
// toggle direction for existing, or add new sorter
const sorter = this.sorters.filter(s => s.field === field)[0]
if (sorter) {
sorter.order = sorter.order === 'desc' ? 'asc' : 'desc'
} else {
this.sorters.push({field, order})
}
// apply multi-column sorting
this.sortingPriority = this.sorters
} else {
% endif
// sort by single column only
this.sorters = [{field, order}]
% if grid.sort_multiple:
// multi-column sort not engaged
this.sortingPriority = []
}
% endif
// nb. always reset to first page when sorting changes
this.currentPage = 1
this.fetchData()
},
% if grid.sort_multiple:
sortingPriorityRemoved(field) {
// prune from active sorters
this.sorters = this.sorters.filter(s => s.field !== field)
// nb. even though we might have just one sorter
// now, we are still technically in multi-sort mode
this.sortingPriority = this.sorters
this.fetchData()
},
% endif
% endif
% if grid.paginated:
% if grid.paginate_on_backend:

View file

@ -181,6 +181,37 @@ class MasterView(View):
This is optional; see also :meth:`get_grid_columns()`.
.. attribute:: sortable
Boolean indicating whether the grid for the :meth:`index()`
view should allow sorting of data. Default is ``True``.
This is used by :meth:`make_model_grid()` to set the grid's
:attr:`~wuttaweb.grids.base.Grid.sortable` flag.
See also :attr:`sort_on_backend` and :attr:`sort_defaults`.
.. attribute:: sort_on_backend
Boolean indicating whether the grid data for the
:meth:`index()` view should be sorted on the backend. Default
is ``True``.
This is used by :meth:`make_model_grid()` to set the grid's
:attr:`~wuttaweb.grids.base.Grid.sort_on_backend` flag.
Only relevant if :attr:`sortable` is true.
.. attribute:: sort_defaults
Optional list of default sorting info. Applicable for both
frontend and backend sorting.
This is used by :meth:`make_model_grid()` to set the grid's
:attr:`~wuttaweb.grids.base.Grid.sort_defaults`.
Only relevant if :attr:`sortable` is true.
.. attribute:: paginated
Boolean indicating whether the grid data for the
@ -246,6 +277,9 @@ class MasterView(View):
# features
listable = True
has_grid = True
sortable = True
sort_on_backend = True
sort_defaults = None
paginated = True
paginate_on_backend = True
creatable = True
@ -1089,6 +1123,10 @@ class MasterView(View):
kwargs['actions'] = actions
kwargs.setdefault('sortable', self.sortable)
kwargs.setdefault('sort_multiple', not self.request.use_oruga)
kwargs.setdefault('sort_on_backend', self.sort_on_backend)
kwargs.setdefault('sort_defaults', self.sort_defaults)
kwargs.setdefault('paginated', self.paginated)
kwargs.setdefault('paginate_on_backend', self.paginate_on_backend)
@ -1136,8 +1174,7 @@ class MasterView(View):
"""
query = self.get_query(session=session)
if query:
return query.all()
return query
return []
def get_query(self, session=None):

View file

@ -45,6 +45,7 @@ class PersonView(MasterView):
model_class = Person
model_title_plural = "People"
route_prefix = 'people'
sort_defaults = 'full_name'
grid_columns = [
'full_name',
@ -53,13 +54,6 @@ class PersonView(MasterView):
'last_name',
]
# TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None):
""" """
model = self.app.model
query = super().get_query(session=session)
return query.order_by(model.Person.full_name)
def configure_grid(self, g):
""" """
super().configure_grid(g)
@ -67,6 +61,12 @@ class PersonView(MasterView):
# full_name
g.set_link('full_name')
# first_name
g.set_link('first_name')
# last_name
g.set_link('last_name')
# TODO: master should handle this?
def configure_form(self, f):
""" """

View file

@ -149,20 +149,34 @@ class SettingView(MasterView):
"""
model_class = Setting
model_title = "Raw Setting"
sort_defaults = 'name'
# TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None):
""" """
model = self.app.model
query = super().get_query(session=session)
return query.order_by(model.Setting.name)
# TODO: master should handle this (per column nullable)
def configure_form(self, f):
""" """
super().configure_form(f)
# name
f.set_validator('name', self.unique_name)
# value
# TODO: master should handle this (per column nullable)
f.set_required('value', False)
def unique_name(self, node, value):
""" """
model = self.app.model
session = self.Session()
query = session.query(model.Setting)\
.filter(model.Setting.name == value)
if self.editing:
name = self.request.matchdict['name']
query = query.filter(model.Setting.name != name)
if query.count():
node.raise_invalid("Setting name must be unique")
def defaults(config, **kwargs):
base = globals()

View file

@ -3,11 +3,13 @@
from unittest import TestCase
from unittest.mock import patch
from sqlalchemy import orm
from paginate import Page
from paginate_sqlalchemy import SqlalchemyOrmPage
from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb.grids import base
from wuttaweb.grids import base as mod
from wuttaweb.forms import FieldList
from tests.util import WebTestCase
@ -15,7 +17,7 @@ from tests.util import WebTestCase
class TestGrid(WebTestCase):
def make_grid(self, request=None, **kwargs):
return base.Grid(request or self.request, **kwargs)
return mod.Grid(request or self.request, **kwargs)
def test_constructor(self):
@ -30,6 +32,59 @@ class TestGrid(WebTestCase):
self.assertIsInstance(grid.columns, FieldList)
self.assertEqual(grid.columns, ['foo', 'bar'])
def test_constructor_sorting(self):
model = self.app.model
# defaults, not sortable
grid = self.make_grid()
self.assertFalse(grid.sortable)
self.assertTrue(grid.sort_on_backend)
self.assertEqual(grid.sorters, {})
self.assertEqual(grid.sort_defaults, [])
# defaults, sortable
grid = self.make_grid(sortable=True)
self.assertTrue(grid.sortable)
self.assertTrue(grid.sort_on_backend)
self.assertEqual(grid.sorters, {})
self.assertEqual(grid.sort_defaults, [])
# sorters may be pre-populated
grid = self.make_grid(model_class=model.Setting, sortable=True)
self.assertEqual(len(grid.sorters), 2)
self.assertIn('name', grid.sorters)
self.assertIn('value', grid.sorters)
self.assertEqual(grid.sort_defaults, [])
# sort defaults as str
grid = self.make_grid(model_class=model.Setting, sortable=True,
sort_defaults='name')
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
# sort defaults as tuple
grid = self.make_grid(model_class=model.Setting, sortable=True,
sort_defaults=('name', 'desc'))
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
# sort defaults as list w/ single tuple
grid = self.make_grid(model_class=model.Setting, sortable=True,
sort_defaults=[('name', 'desc')])
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
# multi-column defaults
grid = self.make_grid(model_class=model.Setting, sortable=True,
sort_multiple=True,
sort_defaults=[('name', 'desc'), ('value', 'asc')])
self.assertTrue(grid.sort_multiple)
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc'),
mod.SortInfo('value', 'asc')])
# multi-column sort disabled for oruga
self.request.use_oruga = True
grid = self.make_grid(model_class=model.Setting, sortable=True,
sort_multiple=True)
self.assertFalse(grid.sort_multiple)
def test_vue_tagname(self):
grid = self.make_grid()
self.assertEqual(grid.vue_tagname, 'wutta-grid')
@ -174,6 +229,9 @@ class TestGrid(WebTestCase):
##############################
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)
@ -187,7 +245,7 @@ class TestGrid(WebTestCase):
# can skip the saving step
self.request.GET = {'pagesize': '10', 'page': '3'}
grid.load_settings(store=False)
grid.load_settings(persist=False)
self.assertEqual(grid.page, 3)
self.assertEqual(self.request.session['grid.foo.page'], 2)
@ -196,19 +254,169 @@ class TestGrid(WebTestCase):
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(persist=False)
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
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_request_has_settings(self):
grid = self.make_grid(key='foo')
self.assertFalse(grid.request_has_settings())
# paging
self.assertFalse(grid.request_has_settings('page'))
with patch.object(self.request, 'GET', new={'pagesize': '20'}):
self.assertTrue(grid.request_has_settings())
self.assertTrue(grid.request_has_settings('page'))
with patch.object(self.request, 'GET', new={'page': '1'}):
self.assertTrue(grid.request_has_settings())
self.assertTrue(grid.request_has_settings('page'))
# sorting
self.assertFalse(grid.request_has_settings('sort'))
with patch.object(self.request, 'GET', new={'sort1key': 'name'}):
self.assertTrue(grid.request_has_settings('sort'))
def test_get_setting(self):
grid = self.make_grid(key='foo')
settings = {}
# default is null
value = grid.get_setting(settings, 'pagesize')
self.assertIsNone(value)
# can read value from user session
self.request.session['grid.foo.pagesize'] = 15
value = grid.get_setting(settings, 'pagesize', src='session')
self.assertEqual(value, 15)
# string value not normalized
self.request.session['grid.foo.pagesize'] = '15'
value = grid.get_setting(settings, 'pagesize', src='session')
self.assertEqual(value, '15')
self.assertNotEqual(value, 15)
# but can be normalized
self.request.session['grid.foo.pagesize'] = '15'
value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
self.assertEqual(value, 15)
# can read value from request
self.request.GET = {'pagesize': '25'}
value = grid.get_setting(settings, 'pagesize', src='request', normalize=int)
self.assertEqual(value, 25)
# null when normalization fails
self.request.GET = {'pagesize': 'invalid'}
value = grid.get_setting(settings, 'pagesize', src='request', normalize=int)
self.assertIsNone(value)
# reset
del self.request.session['grid.foo.pagesize']
self.request.GET = {}
# value can come from provided settings
settings['pagesize'] = '35'
value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
self.assertEqual(value, 35)
def test_update_sort_settings(self):
model = self.app.model
# nothing happens if not sortable
grid = self.make_grid(key='settings', model_class=model.Setting)
settings = {'sorters.length': 0}
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'] = 'asc'
grid.update_sort_settings(settings, src='session')
self.assertEqual(settings['sorters.length'], 0)
# nb. now use a sortable grid
grid = self.make_grid(key='settings', model_class=model.Setting,
sortable=True, sort_on_backend=True)
# settings are updated from session
settings = {'sorters.length': 1, 'sorters.1.key': 'name', 'sorters.1.dir': 'asc'}
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'] = 'asc'
grid.update_sort_settings(settings, src='session')
self.assertEqual(settings['sorters.length'], 1)
self.assertEqual(settings['sorters.1.key'], 'name')
self.assertEqual(settings['sorters.1.dir'], 'asc')
# settings are updated from request
self.request.GET = {'sort1key': 'value', 'sort1dir': 'desc'}
grid.update_sort_settings(settings, src='request')
self.assertEqual(settings['sorters.length'], 1)
self.assertEqual(settings['sorters.1.key'], 'value')
self.assertEqual(settings['sorters.1.dir'], 'desc')
def test_update_page_settings(self):
# nothing happens if not paginated
grid = self.make_grid(key='foo')
settings = {'pagesize': 20, 'page': 1}
self.request.session['grid.foo.pagesize'] = 10
self.request.session['grid.foo.page'] = 2
grid.update_page_settings(settings)
self.assertEqual(settings['pagesize'], 20)
self.assertEqual(settings['page'], 1)
# nb. now use a paginated grid
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
# settings are updated from session
settings = {'pagesize': 20, 'page': 1}
@ -225,45 +433,410 @@ class TestGrid(WebTestCase):
self.assertEqual(settings['page'], 4)
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({})
grid.persist_settings({}, dest='session')
self.assertIsNone(self.request.session['grid.foo.page'])
# provided values are saved
grid.persist_settings({'pagesize': 15, 'page': 3})
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)
##############################
# sorting methods
##############################
def test_make_backend_sorters(self):
model = self.app.model
# default is empty
grid = self.make_grid()
sorters = grid.make_backend_sorters()
self.assertEqual(sorters, {})
# makes sorters if model class
grid = self.make_grid(model_class=model.Setting)
sorters = grid.make_backend_sorters()
self.assertEqual(len(sorters), 2)
self.assertIn('name', sorters)
self.assertIn('value', sorters)
# does not replace supplied sorters
grid = self.make_grid(model_class=model.Setting)
mysorters = {'value': 42}
sorters = grid.make_backend_sorters(mysorters)
self.assertEqual(len(sorters), 2)
self.assertIn('name', sorters)
self.assertIn('value', sorters)
self.assertEqual(sorters['value'], 42)
self.assertEqual(mysorters['value'], 42)
def test_make_sorter(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)
# plain data
grid = self.make_grid(columns=['name', 'value'])
sorter = grid.make_sorter('name')
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})
# model class, but still plain data
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter('name')
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})
# repeat previous test, w/ model property
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter(model.Setting.name)
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})
# sqlalchemy query
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter('name')
sorted_query = sorter(sample_query, 'desc')
sorted_data = sorted_query.all()
self.assertEqual(dict(sorted_data[0]), {'name': 'foo9', 'value': 'nine'})
sorted_query = sorter(sample_query, 'asc')
sorted_data = sorted_query.all()
self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})
# repeat previous test, w/ model property
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter(model.Setting.name)
sorted_query = sorter(sample_query, 'desc')
sorted_data = sorted_query.all()
self.assertEqual(dict(sorted_data[0]), {'name': 'foo9', 'value': 'nine'})
sorted_query = sorter(sample_query, 'asc')
sorted_data = sorted_query.all()
self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})
# sortfunc for "invalid" column will fail when called; however
# it can work for manual sort w/ custom keyfunc
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter('doesnotexist')
self.assertRaises(TypeError, sorter, sample_query, 'desc')
self.assertRaises(KeyError, sorter, sample_data, 'desc')
sorter = grid.make_sorter('doesnotexist', keyfunc=lambda obj: obj['name'])
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(len(sorted_data), 9)
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(len(sorted_data), 9)
# case folding is on by default
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter('value')
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
# results are different with case folding off
grid = self.make_grid(model_class=model.Setting)
sorter = grid.make_sorter('value', foldcase=False)
sorted_data = sorter(sample_data, 'desc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
sorted_data = sorter(sample_data, 'asc')
self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})
def test_set_sorter(self):
model = self.app.model
# explicit sortfunc
grid = self.make_grid()
self.assertEqual(grid.sorters, {})
sortfunc = lambda data, direction: data
grid.set_sorter('foo', sortfunc)
self.assertIs(grid.sorters['foo'], sortfunc)
# auto from model property
grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
self.assertEqual(grid.sorters, {})
grid.set_sorter('name', model.Setting.name)
self.assertTrue(callable(grid.sorters['name']))
# auto from column name
grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
self.assertEqual(grid.sorters, {})
grid.set_sorter('name', 'name')
self.assertTrue(callable(grid.sorters['name']))
# auto from key
grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
self.assertEqual(grid.sorters, {})
grid.set_sorter('name')
self.assertTrue(callable(grid.sorters['name']))
def test_remove_sorter(self):
model = self.app.model
# basics
grid = self.make_grid(model_class=model.Setting, sortable=True)
self.assertEqual(len(grid.sorters), 2)
self.assertIn('name', grid.sorters)
self.assertIn('value', grid.sorters)
grid.remove_sorter('value')
self.assertNotIn('value', grid.sorters)
def test_set_sort_defaults(self):
model = self.app.model
grid = self.make_grid(model_class=model.Setting, sortable=True)
self.assertEqual(grid.sort_defaults, [])
# can set just sortkey
grid.set_sort_defaults('name')
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
# can set sortkey, sortdir
grid.set_sort_defaults('name', 'desc')
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
# can set sortkey, sortdir as tuple
grid.set_sort_defaults(('value', 'asc'))
self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])
# can set as list
grid.sort_multiple = True
grid.set_sort_defaults([('value', 'asc'), ('name', 'desc')])
self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc'),
mod.SortInfo('name', 'desc')])
# list is pruned if multi-sort disabled
grid.sort_multiple = False
grid.set_sort_defaults([('value', 'asc'), ('name', 'desc')])
self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])
# error if any other single arg
self.assertRaises(ValueError, grid.set_sort_defaults, 42)
# error if more than 2 args
self.assertRaises(ValueError, grid.set_sort_defaults, 'name', 'asc', 'value', 'desc')
def test_is_sortable(self):
model = self.app.model
# basics, frontend sorting
grid = self.make_grid(model_class=model.Setting, sortable=True, sort_on_backend=False)
self.assertTrue(grid.is_sortable('name'))
self.assertTrue(grid.is_sortable('value'))
grid.remove_sorter('value')
# nb. columns are always sortable for frontend, despite remove_sorter()
self.assertTrue(grid.is_sortable('value'))
# nb. when grid is not sortable, no column is either
grid.sortable = False
self.assertFalse(grid.is_sortable('name'))
# same test but with backend sorting
grid = self.make_grid(model_class=model.Setting, sortable=True, sort_on_backend=True)
self.assertTrue(grid.is_sortable('name'))
self.assertTrue(grid.is_sortable('value'))
grid.remove_sorter('value')
self.assertFalse(grid.is_sortable('value'))
# nb. when grid is not sortable, no column is either
grid.sortable = False
self.assertFalse(grid.is_sortable('name'))
##############################
# data methods
##############################
def test_get_visible_data(self):
data = [
{'foo': 1, 'bar': 1},
{'foo': 2, 'bar': 2},
{'foo': 3, 'bar': 3},
{'foo': 4, 'bar': 4},
{'foo': 5, 'bar': 5},
{'foo': 6, 'bar': 6},
{'foo': 7, 'bar': 7},
{'foo': 8, 'bar': 8},
{'foo': 9, 'bar': 9},
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'},
]
grid = self.make_grid(data=data,
columns=['foo', 'bar'],
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)
# data is sorted and paginated
grid = self.make_grid(model_class=model.Setting,
data=sample_query,
sortable=True, sort_on_backend=True,
sort_defaults=('name', 'desc'),
paginated=True, paginate_on_backend=True,
pagesize=4, page=2)
grid.load_settings()
visible = grid.get_visible_data()
self.assertEqual(len(visible), 4)
self.assertEqual(visible[0], {'foo': 5, 'bar': 5})
self.assertEqual([s.name for s in visible], ['foo5', 'foo4', 'foo3', 'foo2'])
def test_sort_data(self):
model = self.app.model
sample_data = [
{'name': 'foo1', 'value': 'ONE'},
{'name': 'foo2', 'value': 'two'},
{'name': 'foo3', 'value': 'ggg'},
{'name': 'foo4', 'value': 'ggg'},
{'name': 'foo5', 'value': 'ggg'},
{'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')
# multi-column sorting for list data
sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
{'key': 'name', 'dir': 'asc'}])
self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})
# multi-column sorting for query
sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'},
{'key': 'name', 'dir': 'asc'}])
self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})
# cannot sort data if sortfunc is missing for column
grid.remove_sorter('name')
sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
{'key': 'name', 'dir': 'asc'}])
# nb. sorted data is in same order as original sample (not sorted)
self.assertEqual(sorted_data[0]['name'], 'foo1')
self.assertEqual(sorted_data[-1]['name'], 'foo9')
def test_paginate_data(self):
grid = self.make_grid()
pager = grid.paginate_data([])
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)
# basic list pager
grid = self.make_grid(paginated=True, paginate_on_backend=True)
pager = grid.paginate_data(sample_data)
self.assertIsInstance(pager, Page)
# basic query pager
grid = self.make_grid(paginated=True, paginate_on_backend=True)
pager = grid.paginate_data(sample_query)
self.assertIsInstance(pager, SqlalchemyOrmPage)
# page is reset to 1 for empty data
self.request.session['grid.foo.page'] = 2
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
grid.load_settings()
self.assertEqual(grid.page, 2)
self.assertEqual(self.request.session['grid.foo.page'], 2)
pager = grid.paginate_data(sample_data)
self.assertEqual(pager.page, 1)
self.assertEqual(grid.page, 1)
self.assertEqual(self.request.session['grid.foo.page'], 1)
##############################
# rendering methods
##############################
@ -295,6 +868,24 @@ class TestGrid(WebTestCase):
self.assertEqual(first['field'], 'foo')
self.assertEqual(first['label'], 'Foo')
def test_get_vue_active_sorters(self):
model = self.app.model
# empty
grid = self.make_grid(key='foo', sortable=True, sort_on_backend=True)
grid.load_settings()
sorters = grid.get_vue_active_sorters()
self.assertEqual(sorters, [])
# format is different
grid = self.make_grid(key='settings', model_class=model.Setting,
sortable=True, sort_on_backend=True,
sort_defaults='name')
grid.load_settings()
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
sorters = grid.get_vue_active_sorters()
self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}])
def test_get_vue_data(self):
# empty if no columns defined
@ -311,7 +902,7 @@ class TestGrid(WebTestCase):
self.assertEqual(data, [{'foo': 'bar'}])
# if grid has actions, that list may be supplemented
grid.actions.append(base.GridAction(self.request, 'view', url='/blarg'))
grid.actions.append(mod.GridAction(self.request, 'view', url='/blarg'))
data = grid.get_vue_data()
self.assertIsNot(data, mydata)
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])
@ -351,7 +942,7 @@ class TestGridAction(TestCase):
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
def make_action(self, key, **kwargs):
return base.GridAction(self.request, key, **kwargs)
return mod.GridAction(self.request, key, **kwargs)
def test_render_icon(self):

View file

@ -4,6 +4,7 @@ import functools
from unittest import TestCase
from unittest.mock import MagicMock, patch
from sqlalchemy import orm
from pyramid import testing
from pyramid.response import Response
from pyramid.httpexceptions import HTTPNotFound
@ -518,11 +519,13 @@ class TestMasterView(WebTestCase):
data = view.get_grid_data(session=self.session)
self.assertEqual(data, [])
# basic logic with Setting model
# grid with model class will produce data query
with patch.multiple(master.MasterView, create=True,
model_class=model.Setting):
view = master.MasterView(self.request)
data = view.get_grid_data(session=self.session)
query = view.get_grid_data(session=self.session)
self.assertIsInstance(query, orm.Query)
data = query.all()
self.assertEqual(len(data), 1)
self.assertIs(data[0], setting)

View file

@ -2,9 +2,10 @@
from unittest.mock import patch
import colander
from pyramid.httpexceptions import HTTPNotFound
from wuttaweb.views import settings
from wuttaweb.views import settings as mod
from tests.util import WebTestCase
@ -15,7 +16,7 @@ class TestAppInfoView(WebTestCase):
self.pyramid_config.include('wuttaweb.views.essential')
def make_view(self):
return settings.AppInfoView(self.request)
return mod.AppInfoView(self.request)
def test_index(self):
# sanity/coverage check
@ -36,19 +37,21 @@ class TestAppInfoView(WebTestCase):
class TestSettingView(WebTestCase):
def make_view(self):
return settings.SettingView(self.request)
return mod.SettingView(self.request)
def test_get_grid_data(self):
# empty data by default
view = self.make_view()
data = view.get_grid_data(session=self.session)
query = view.get_grid_data(session=self.session)
data = query.all()
self.assertEqual(len(data), 0)
# unless we save some settings
self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit()
data = view.get_grid_data(session=self.session)
query = view.get_grid_data(session=self.session)
data = query.all()
self.assertEqual(len(data), 1)
def test_configure_form(self):
@ -58,3 +61,23 @@ class TestSettingView(WebTestCase):
view.configure_form(form)
self.assertIn('value', form.required_fields)
self.assertFalse(form.required_fields['value'])
def test_unique_name(self):
model = self.app.model
view = self.make_view()
setting = model.Setting(name='foo')
self.session.add(setting)
self.session.commit()
with patch.object(view, 'Session', return_value=self.session):
# invalid if same name in data
node = colander.SchemaNode(colander.String(), name='name')
self.assertRaises(colander.Invalid, view.unique_name, node, 'foo')
# but not if name belongs to current setting
view.editing = True
self.request.matchdict = {'name': 'foo'}
node = colander.SchemaNode(colander.String(), name='name')
self.assertIsNone(view.unique_name(node, 'foo'))