feat: add initial support for proper grid filters
only "text contains" filter supported so far, more to come as needed
This commit is contained in:
parent
9751bf4c2e
commit
1443f5253f
|
@ -39,6 +39,7 @@ from webhelpers2.html import HTML
|
|||
|
||||
from wuttaweb.db import Session
|
||||
from wuttaweb.util import FieldList, get_model_fields, make_json_safe
|
||||
from wuttjamaican.util import UNSPECIFIED
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
@ -304,6 +305,12 @@ class Grid:
|
|||
|
||||
See also :meth:`set_filter()`.
|
||||
|
||||
.. attribute:: filter_defaults
|
||||
|
||||
Dict containing default state preferences for the filters.
|
||||
|
||||
See also :meth:`set_filter_defaults()`.
|
||||
|
||||
.. attribute:: joiners
|
||||
|
||||
Dict of "joiner" functions for use with backend filtering and
|
||||
|
@ -337,6 +344,7 @@ class Grid:
|
|||
searchable_columns=None,
|
||||
filterable=False,
|
||||
filters=None,
|
||||
filter_defaults=None,
|
||||
joiners=None,
|
||||
):
|
||||
self.request = request
|
||||
|
@ -388,6 +396,7 @@ class Grid:
|
|||
self.filters = self.make_backend_filters()
|
||||
else:
|
||||
self.filters = {}
|
||||
self.set_filter_defaults(**(filter_defaults or {}))
|
||||
|
||||
def get_columns(self):
|
||||
"""
|
||||
|
@ -1025,14 +1034,8 @@ class Grid:
|
|||
|
||||
def make_filter(self, columninfo, **kwargs):
|
||||
"""
|
||||
Creates and returns a
|
||||
:class:`~wuttaweb.grids.filters.GridFilter` instance suitable
|
||||
for use as a backend filter on the given column.
|
||||
|
||||
.. warning::
|
||||
|
||||
This method is not yet implemented; subclass *must*
|
||||
override.
|
||||
Create and return a :class:`GridFilter` instance suitable for
|
||||
use on the given column.
|
||||
|
||||
Code usually does not need to call this directly. See also
|
||||
:meth:`set_filter()`, which calls this method automatically.
|
||||
|
@ -1040,16 +1043,24 @@ class Grid:
|
|||
:param columninfo: Can be either a model property (see below),
|
||||
or a column name.
|
||||
|
||||
:returns: A :class:`~wuttaweb.grids.filters.GridFilter`
|
||||
instance suitable for backend sorting.
|
||||
:returns: A :class:`GridFilter` instance.
|
||||
"""
|
||||
model_property = None
|
||||
if isinstance(columninfo, str):
|
||||
key = columninfo
|
||||
if self.model_class:
|
||||
try:
|
||||
mapper = sa.inspect(self.model_class)
|
||||
except sa.exc.NoInspectionAvailable:
|
||||
pass
|
||||
else:
|
||||
model_property = mapper.get_property(key)
|
||||
if not model_property:
|
||||
raise ValueError(f"cannot locate model property for key: {key}")
|
||||
else:
|
||||
model_property = columninfo
|
||||
key = model_property.key
|
||||
|
||||
return GridFilter(self.request, key, **kwargs)
|
||||
return GridFilter(self.request, model_property, **kwargs)
|
||||
|
||||
def set_filter(self, key, filterinfo=None, **kwargs):
|
||||
"""
|
||||
|
@ -1096,6 +1107,31 @@ class Grid:
|
|||
"""
|
||||
self.filters.pop(key, None)
|
||||
|
||||
def set_filter_defaults(self, **defaults):
|
||||
"""
|
||||
Set default state preferences for the grid filters.
|
||||
|
||||
These preferences will affect the initial grid display, until
|
||||
user requests a different filtering method.
|
||||
|
||||
Each kwarg should be named by filter key, and the value should
|
||||
be a dict of preferences for that filter. For instance::
|
||||
|
||||
grid.set_filter_defaults(name={'active': True,
|
||||
'verb': 'contains',
|
||||
'value': 'foo'},
|
||||
value={'active': True})
|
||||
|
||||
Filter defaults are tracked via :attr:`filter_defaults`.
|
||||
"""
|
||||
filter_defaults = dict(getattr(self, 'filter_defaults', {}))
|
||||
|
||||
for key, values in defaults.items():
|
||||
filtr = filter_defaults.setdefault(key, {})
|
||||
filtr.update(values)
|
||||
|
||||
self.filter_defaults = filter_defaults
|
||||
|
||||
##############################
|
||||
# paging methods
|
||||
##############################
|
||||
|
@ -1188,9 +1224,13 @@ class Grid:
|
|||
settings = {}
|
||||
if self.filterable:
|
||||
for filtr in self.filters.values():
|
||||
settings[f'filter.{filtr.key}.active'] = filtr.default_active
|
||||
settings[f'filter.{filtr.key}.verb'] = filtr.default_verb
|
||||
settings[f'filter.{filtr.key}.value'] = filtr.default_value
|
||||
defaults = self.filter_defaults.get(filtr.key, {})
|
||||
settings[f'filter.{filtr.key}.active'] = defaults.get('active',
|
||||
filtr.default_active)
|
||||
settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
|
||||
filtr.default_verb)
|
||||
settings[f'filter.{filtr.key}.value'] = defaults.get('value',
|
||||
filtr.default_value)
|
||||
if self.sortable:
|
||||
if self.sort_defaults:
|
||||
# nb. as of writing neither Buefy nor Oruga support a
|
||||
|
@ -1205,21 +1245,16 @@ class Grid:
|
|||
settings['pagesize'] = self.pagesize
|
||||
settings['page'] = self.page
|
||||
|
||||
# TODO
|
||||
# # If user has default settings on file, apply those first.
|
||||
# if self.user_has_defaults():
|
||||
# self.apply_user_defaults(settings)
|
||||
|
||||
# TODO
|
||||
# # If request contains instruction to reset to default filters, then we
|
||||
# # can skip the rest of the request/session checks.
|
||||
# if self.request.GET.get('reset-to-default-filters') == 'true':
|
||||
# pass
|
||||
|
||||
# update settings dict based on what we find in the request
|
||||
# and/or user session. always prioritize the former.
|
||||
|
||||
if self.request_has_settings('filter'):
|
||||
# nb. do not read settings if user wants a reset
|
||||
if self.request.GET.get('reset-view'):
|
||||
# at this point we only have default settings, and we want
|
||||
# to keep those *and* persist them for next time, below
|
||||
pass
|
||||
|
||||
elif self.request_has_settings('filter'):
|
||||
self.update_filter_settings(settings, src='request')
|
||||
if self.request_has_settings('sort'):
|
||||
self.update_sort_settings(settings, src='request')
|
||||
|
@ -1250,19 +1285,13 @@ class Grid:
|
|||
if persist:
|
||||
self.persist_settings(settings, dest='session')
|
||||
|
||||
# TODO
|
||||
# # If request contained instruction to save current settings as defaults
|
||||
# # for the current user, then do that.
|
||||
# if self.request.GET.get('save-current-filters-as-defaults') == 'true':
|
||||
# self.persist_settings(settings, dest='defaults')
|
||||
|
||||
# update ourself to reflect settings dict..
|
||||
|
||||
# filtering
|
||||
if self.filterable:
|
||||
for filtr in self.filters.values():
|
||||
filtr.active = settings[f'filter.{filtr.key}.active']
|
||||
filtr.verb = settings[f'filter.{filtr.key}.verb']
|
||||
filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.default_verb
|
||||
filtr.value = settings[f'filter.{filtr.key}.value']
|
||||
|
||||
# sorting
|
||||
|
@ -1497,20 +1526,45 @@ class Grid:
|
|||
|
||||
return data
|
||||
|
||||
@property
|
||||
def active_filters(self):
|
||||
"""
|
||||
Returns the list of currently active filters.
|
||||
|
||||
This inspects each :class:`GridFilter` in :attr:`filters` and
|
||||
only returns the ones marked active.
|
||||
"""
|
||||
return [filtr for filtr in self.filters.values()
|
||||
if filtr.active]
|
||||
|
||||
def filter_data(self, data, filters=None):
|
||||
"""
|
||||
Filter the given data and return the result. This is called
|
||||
by :meth:`get_visible_data()`.
|
||||
|
||||
:param filters: Optional list of filters to use. If not
|
||||
specified, the grid's "active" filters are used.
|
||||
|
||||
.. warning::
|
||||
|
||||
This method is not yet implemented; subclass *must*
|
||||
override.
|
||||
specified, the grid's :attr:`active_filters` are used.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
if filters is None:
|
||||
filters = self.active_filters
|
||||
if not filters:
|
||||
return data
|
||||
|
||||
for filtr in filters:
|
||||
key = filtr.key
|
||||
|
||||
if key in self.joiners and key not in self.joined:
|
||||
data = self.joiners[key](data)
|
||||
self.joined.add(key)
|
||||
|
||||
try:
|
||||
data = filtr.apply_filter(data)
|
||||
except VerbNotSupported as error:
|
||||
log.warning("verb not supported for '%s' filter: %s", key, error.verb)
|
||||
except:
|
||||
log.exception("filtering data by '%s' failed!", key)
|
||||
|
||||
return data
|
||||
|
||||
def sort_data(self, data, sorters=None):
|
||||
"""
|
||||
|
@ -1588,6 +1642,58 @@ class Grid:
|
|||
# rendering methods
|
||||
##############################
|
||||
|
||||
def render_table_element(
|
||||
self,
|
||||
form=None,
|
||||
template='/grids/table_element.mako',
|
||||
**context):
|
||||
"""
|
||||
Render a simple Vue table element for the grid.
|
||||
|
||||
This is what you want for a "simple" grid which does require a
|
||||
unique Vue component, but can instead use the standard table
|
||||
component.
|
||||
|
||||
This returns something like:
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
<b-table :data="gridData['mykey']">
|
||||
<!-- columns etc. -->
|
||||
</b-table>
|
||||
|
||||
See :meth:`render_vue_template()` for a more complete variant.
|
||||
|
||||
Actual output will of course depend on grid attributes,
|
||||
:attr:`key`, :attr:`columns` etc.
|
||||
|
||||
:param form: Reference to the
|
||||
:class:`~wuttaweb.forms.base.Form` instance which
|
||||
"contains" this grid. This is needed in order to ensure
|
||||
the grid data is available to the form Vue component.
|
||||
|
||||
:param template: Path to Mako template which is used to render
|
||||
the output.
|
||||
|
||||
.. note::
|
||||
|
||||
The above example shows ``gridData['mykey']`` as the Vue
|
||||
data reference. This should "just work" if you provide the
|
||||
correct ``form`` arg and the grid is contained directly by
|
||||
that form's Vue component.
|
||||
|
||||
However, this may not account for all use cases. For now
|
||||
we wait and see what comes up, but know the dust may not
|
||||
yet be settled here.
|
||||
"""
|
||||
|
||||
# nb. must register data for inclusion on page template
|
||||
if form:
|
||||
form.add_grid_vue_data(self)
|
||||
|
||||
# otherwise logic is the same, just different template
|
||||
return self.render_vue_template(template=template, **context)
|
||||
|
||||
def render_vue_tag(self, **kwargs):
|
||||
"""
|
||||
Render the Vue component tag for the grid.
|
||||
|
@ -1649,58 +1755,6 @@ class Grid:
|
|||
output = render(template, context)
|
||||
return HTML.literal(output)
|
||||
|
||||
def render_table_element(
|
||||
self,
|
||||
form=None,
|
||||
template='/grids/element.mako',
|
||||
**context):
|
||||
"""
|
||||
Render a simple Vue table element for the grid.
|
||||
|
||||
This is what you want for a "simple" grid which does require a
|
||||
unique Vue component, but can instead use the standard table
|
||||
component.
|
||||
|
||||
This returns something like:
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
<b-table :data="gridData['mykey']">
|
||||
<!-- columns etc. -->
|
||||
</b-table>
|
||||
|
||||
See :meth:`render_vue_template()` for a more complete variant.
|
||||
|
||||
Actual output will of course depend on grid attributes,
|
||||
:attr:`key`, :attr:`columns` etc.
|
||||
|
||||
:param form: Reference to the
|
||||
:class:`~wuttaweb.forms.base.Form` instance which
|
||||
"contains" this grid. This is needed in order to ensure
|
||||
the grid data is available to the form Vue component.
|
||||
|
||||
:param template: Path to Mako template which is used to render
|
||||
the output.
|
||||
|
||||
.. note::
|
||||
|
||||
The above example shows ``gridData['mykey']`` as the Vue
|
||||
data reference. This should "just work" if you provide the
|
||||
correct ``form`` arg and the grid is contained directly by
|
||||
that form's Vue component.
|
||||
|
||||
However, this may not account for all use cases. For now
|
||||
we wait and see what comes up, but know the dust may not
|
||||
yet be settled here.
|
||||
"""
|
||||
|
||||
# nb. must register data for inclusion on page template
|
||||
if form:
|
||||
form.add_grid_vue_data(self)
|
||||
|
||||
# otherwise logic is the same, just different template
|
||||
return self.render_vue_template(template=template, **context)
|
||||
|
||||
def render_vue_finalize(self):
|
||||
"""
|
||||
Render the Vue "finalize" script for the grid.
|
||||
|
@ -1781,6 +1835,26 @@ class Grid:
|
|||
'order': sorter['dir']})
|
||||
return sorters
|
||||
|
||||
def get_vue_filters(self):
|
||||
"""
|
||||
Returns a list of Vue-compatible filter definitions.
|
||||
|
||||
This returns the full set of :attr:`filters` but represents
|
||||
each as a simple dict with the filter state.
|
||||
"""
|
||||
filters = []
|
||||
for filtr in self.filters.values():
|
||||
filters.append({
|
||||
'key': filtr.key,
|
||||
'active': filtr.active,
|
||||
'visible': filtr.active,
|
||||
'verbs': filtr.verbs,
|
||||
'verb': filtr.verb,
|
||||
'value': filtr.value,
|
||||
'label': filtr.label,
|
||||
})
|
||||
return filters
|
||||
|
||||
def get_vue_data(self):
|
||||
"""
|
||||
Returns a list of Vue-compatible data records.
|
||||
|
@ -2009,24 +2083,162 @@ class GridAction:
|
|||
return self.url
|
||||
|
||||
|
||||
# TODO: this needs plenty of work yet..and probably will move?
|
||||
class GridFilter:
|
||||
""" """
|
||||
"""
|
||||
Filter option for a grid. Represents both the "features" as well
|
||||
as "state" for the filter.
|
||||
|
||||
:param request: Current :term:`request` object.
|
||||
|
||||
:param model_property: Property of a model class, representing the
|
||||
column by which to filter. For instance,
|
||||
``model.Person.full_name``.
|
||||
|
||||
:param \**kwargs: Any additional kwargs will be set as attributes
|
||||
on the filter instance.
|
||||
|
||||
Filter instances have the following attributes:
|
||||
|
||||
.. attribute:: key
|
||||
|
||||
Unique key for the filter. This often corresponds to a "column
|
||||
name" for the grid, but not always.
|
||||
|
||||
.. attribute:: label
|
||||
|
||||
Display label for the filter field.
|
||||
|
||||
.. attribute:: active
|
||||
|
||||
Boolean indicating whether the filter is currently active.
|
||||
|
||||
See also :attr:`verb` and :attr:`value`.
|
||||
|
||||
.. attribute:: verb
|
||||
|
||||
Verb for current filter, if :attr:`active` is true.
|
||||
|
||||
See also :attr:`value`.
|
||||
|
||||
.. attribute:: value
|
||||
|
||||
Value for current filter, if :attr:`active` is true.
|
||||
|
||||
See also :attr:`verb`.
|
||||
|
||||
.. attribute:: default_active
|
||||
|
||||
Boolean indicating whether the filter should be active by
|
||||
default, i.e. when first displaying the grid.
|
||||
|
||||
See also :attr:`default_verb` and :attr:`default_value`.
|
||||
|
||||
.. attribute:: default_verb
|
||||
|
||||
Filter verb to use by default. This will be auto-selected when
|
||||
the filter is first activated, or when first displaying the
|
||||
grid if :attr:`default_active` is true.
|
||||
|
||||
See also :attr:`default_value`.
|
||||
|
||||
.. attribute:: default_value
|
||||
|
||||
Filter value to use by default. This will be auto-populated
|
||||
when the filter is first activated, or when first displaying
|
||||
the grid if :attr:`default_active` is true.
|
||||
|
||||
See also :attr:`default_verb`.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request,
|
||||
key,
|
||||
model_property,
|
||||
label=None,
|
||||
default_active=False,
|
||||
default_verb=None,
|
||||
default_value=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.request = request
|
||||
self.key = key
|
||||
self.config = self.request.wutta_config
|
||||
self.app = self.config.get_app()
|
||||
|
||||
self.model_property = model_property
|
||||
self.key = self.model_property.key
|
||||
self.label = label or self.app.make_title(self.key)
|
||||
|
||||
self.default_active = default_active
|
||||
self.default_verb = default_verb
|
||||
self.active = self.default_active
|
||||
|
||||
self.verbs = ['contains'] # TODO
|
||||
self.default_verb = default_verb or self.verbs[0]
|
||||
self.verb = self.default_verb
|
||||
|
||||
self.default_value = default_value
|
||||
self.value = self.default_value
|
||||
|
||||
self.__dict__.update(kwargs)
|
||||
|
||||
def __repr__(self):
|
||||
return ("GridFilter("
|
||||
f"key='{self.key}', "
|
||||
f"active={self.active}, "
|
||||
f"verb='{self.verb}', "
|
||||
f"value={repr(self.value)})")
|
||||
|
||||
def apply_filter(self, data, verb=None, value=UNSPECIFIED):
|
||||
"""
|
||||
Filter the given data set according to a verb/value pair.
|
||||
|
||||
If verb and/or value are not specified, will use :attr:`verb`
|
||||
and/or :attr:`value` instead.
|
||||
|
||||
This method does not directly filter the data; rather it
|
||||
delegates (based on ``verb``) to some other method. The
|
||||
latter may choose *not* to filter the data, e.g. if ``value``
|
||||
is empty, in which case this may return the original data set
|
||||
unchanged.
|
||||
|
||||
:returns: The (possibly) filtered data set.
|
||||
"""
|
||||
if verb is None:
|
||||
verb = self.verb
|
||||
if not verb:
|
||||
log.warn("missing verb for '%s' filter, will use default verb: %s",
|
||||
self.key, self.default_verb)
|
||||
verb = self.default_verb
|
||||
|
||||
if value is UNSPECIFIED:
|
||||
value = self.value
|
||||
|
||||
func = getattr(self, f'filter_{verb}', None)
|
||||
if not func:
|
||||
raise VerbNotSupported(verb)
|
||||
|
||||
return func(data, value)
|
||||
|
||||
def filter_contains(self, query, value):
|
||||
"""
|
||||
Filter data with a full 'ILIKE' query.
|
||||
"""
|
||||
if value is None or value == '':
|
||||
return query
|
||||
|
||||
criteria = []
|
||||
for val in value.split():
|
||||
val = val.replace('_', r'\_')
|
||||
val = f'%{val}%'
|
||||
criteria.append(self.model_property.ilike(val))
|
||||
|
||||
return query.filter(sa.and_(*criteria))
|
||||
|
||||
|
||||
class VerbNotSupported(Exception):
|
||||
""" """
|
||||
|
||||
def __init__(self, verb):
|
||||
self.verb = verb
|
||||
|
||||
def __str__(self):
|
||||
return f"unknown filter verb not supported: {self.verb}"
|
||||
|
|
|
@ -151,6 +151,33 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
##############################
|
||||
## grids
|
||||
##############################
|
||||
|
||||
.wutta-filter {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wutta-filter .button.filter-toggle {
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.wutta-filter .button.filter-toggle,
|
||||
.wutta-filter .filter-verb {
|
||||
min-width: 15rem;
|
||||
}
|
||||
|
||||
.wutta-filter .filter-verb .select,
|
||||
.wutta-filter .filter-verb .select select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
##############################
|
||||
## forms
|
||||
##############################
|
||||
|
||||
.wutta-form-wrapper {
|
||||
margin-left: 5rem;
|
||||
margin-top: 2rem;
|
||||
|
|
|
@ -1,6 +1,90 @@
|
|||
## -*- coding: utf-8; -*-
|
||||
|
||||
<script type="text/x-template" id="${grid.vue_tagname}-template">
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;">
|
||||
|
||||
% if grid.filterable:
|
||||
<form action="${request.path_url}" method="GET"
|
||||
@submit.prevent="applyFilters()">
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
<wutta-filter v-for="filtr in filters"
|
||||
:key="filtr.key"
|
||||
:filter="filtr"
|
||||
:is-small="smallFilters"
|
||||
ref="gridFilters" />
|
||||
|
||||
<div class="buttons">
|
||||
<b-button type="is-primary"
|
||||
native-type="submit"
|
||||
icon-pack="fas"
|
||||
icon-left="filter"
|
||||
:size="smallFilters ? 'is-small' : null">
|
||||
Apply Filters
|
||||
</b-button>
|
||||
|
||||
<b-button v-if="!addFilterShow"
|
||||
@click="addFilterInit()"
|
||||
icon-pack="fas"
|
||||
icon-left="plus"
|
||||
:size="smallFilters ? 'is-small' : null">
|
||||
Add Filter
|
||||
</b-button>
|
||||
|
||||
<b-autocomplete v-if="addFilterShow"
|
||||
ref="addFilterAutocomplete"
|
||||
:data="addFilterChoices"
|
||||
v-model="addFilterTerm"
|
||||
placeholder="Add Filter"
|
||||
field="key"
|
||||
:custom-formatter="formatAddFilterItem"
|
||||
open-on-focus
|
||||
keep-first
|
||||
clearable
|
||||
clear-on-select
|
||||
@select="addFilterSelect"
|
||||
icon-pack="fas"
|
||||
:size="smallFilters ? 'is-small' : null" />
|
||||
|
||||
<b-button @click="resetView()"
|
||||
icon-pack="fas"
|
||||
icon-left="undo"
|
||||
:size="smallFilters ? 'is-small' : null">
|
||||
Reset View
|
||||
</b-button>
|
||||
|
||||
<b-button v-show="activeFilters"
|
||||
@click="clearFilters()"
|
||||
icon-pack="fas"
|
||||
icon-left="trash"
|
||||
:size="smallFilters ? 'is-small' : null">
|
||||
No Filters
|
||||
</b-button>
|
||||
|
||||
## TODO: this semi-works but is not persisted for user
|
||||
## <b-button v-if="!smallFilters"
|
||||
## @click="smallFilters = true"
|
||||
## icon-pack="fas"
|
||||
## icon-left="compress"
|
||||
## title="toggle filter size" />
|
||||
##
|
||||
## <span v-if="smallFilters">
|
||||
## <b-button @click="smallFilters = false"
|
||||
## icon-pack="fas"
|
||||
## icon-left="expand"
|
||||
## size="is-small"
|
||||
## title="toggle filter size" />
|
||||
## </span>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</form>
|
||||
% endif
|
||||
|
||||
</div>
|
||||
|
||||
<${b}-table :data="data"
|
||||
:loading="loading"
|
||||
narrowed
|
||||
|
@ -125,6 +209,7 @@
|
|||
% endif
|
||||
|
||||
</${b}-table>
|
||||
</div>
|
||||
</script>
|
||||
|
||||
<script>
|
||||
|
@ -138,6 +223,14 @@
|
|||
## nb. this tracks whether grid.fetchFirstData() happened
|
||||
fetchedFirstData: false,
|
||||
|
||||
## filtering
|
||||
% if grid.filterable:
|
||||
filters: ${json.dumps(grid.get_vue_filters())|n},
|
||||
addFilterShow: false,
|
||||
addFilterTerm: '',
|
||||
smallFilters: false,
|
||||
% endif
|
||||
|
||||
## sorting
|
||||
% if grid.sortable:
|
||||
sorters: ${json.dumps(grid.get_vue_active_sorters())|n},
|
||||
|
@ -175,6 +268,47 @@
|
|||
template: '#${grid.vue_tagname}-template',
|
||||
computed: {
|
||||
|
||||
% if grid.filterable:
|
||||
|
||||
addFilterChoices() {
|
||||
|
||||
// parse list of search terms
|
||||
const terms = []
|
||||
for (let term of this.addFilterTerm.toLowerCase().split(' ')) {
|
||||
term = term.trim()
|
||||
if (term) {
|
||||
terms.push(term)
|
||||
}
|
||||
}
|
||||
|
||||
// show all if no search terms
|
||||
if (!terms.length) {
|
||||
return this.filters
|
||||
}
|
||||
|
||||
// only show filters matching all search terms
|
||||
return this.filters.filter(option => {
|
||||
let label = option.label.toLowerCase()
|
||||
for (let term of terms) {
|
||||
if (label.indexOf(term) < 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
},
|
||||
|
||||
activeFilters() {
|
||||
for (let filtr of this.filters) {
|
||||
if (filtr.active) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
% endif
|
||||
|
||||
% if not grid.paginate_on_backend:
|
||||
|
||||
pagerStats() {
|
||||
|
@ -245,9 +379,12 @@
|
|||
this.fetchedFirstData = true
|
||||
},
|
||||
|
||||
async fetchData() {
|
||||
async fetchData(params) {
|
||||
if (params === undefined || params === null) {
|
||||
params = this.getBasicParams()
|
||||
}
|
||||
|
||||
let params = new URLSearchParams(this.getBasicParams())
|
||||
params = new URLSearchParams(params)
|
||||
if (!params.has('partial')) {
|
||||
params.append('partial', true)
|
||||
}
|
||||
|
@ -281,6 +418,129 @@
|
|||
})
|
||||
},
|
||||
|
||||
resetView() {
|
||||
this.loading = true
|
||||
|
||||
// use current url proper, plus reset param
|
||||
let url = '?reset-view=true'
|
||||
|
||||
// add current hash, to preserve that in redirect
|
||||
if (location.hash) {
|
||||
url += '&hash=' + location.hash.slice(1)
|
||||
}
|
||||
|
||||
location.href = url
|
||||
},
|
||||
|
||||
% if grid.filterable:
|
||||
|
||||
formatAddFilterItem(filtr) {
|
||||
return filtr.label || filtr.key
|
||||
},
|
||||
|
||||
addFilterInit() {
|
||||
this.addFilterShow = true
|
||||
this.$nextTick(() => {
|
||||
const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
|
||||
input.addEventListener('keydown', this.addFilterKeydown)
|
||||
this.$refs.addFilterAutocomplete.focus()
|
||||
})
|
||||
},
|
||||
|
||||
addFilterKeydown(event) {
|
||||
|
||||
// ESC will clear searchbox
|
||||
if (event.which == 27) {
|
||||
this.addFilterHide()
|
||||
}
|
||||
},
|
||||
|
||||
addFilterHide() {
|
||||
const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
|
||||
input.removeEventListener('keydown', this.addFilterKeydown)
|
||||
this.addFilterTerm = ''
|
||||
this.addFilterShow = false
|
||||
},
|
||||
|
||||
addFilterSelect(filtr) {
|
||||
this.addFilter(filtr.key)
|
||||
this.addFilterHide()
|
||||
},
|
||||
|
||||
findFilter(key) {
|
||||
for (let filtr of this.filters) {
|
||||
if (filtr.key == key) {
|
||||
return filtr
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
findFilterComponent(key) {
|
||||
for (let filtr of this.$refs.gridFilters) {
|
||||
if (filtr.filter.key == key) {
|
||||
return filtr
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addFilter(key) {
|
||||
|
||||
// show the filter
|
||||
let filtr = this.findFilter(key)
|
||||
filtr.active = true
|
||||
filtr.visible = true
|
||||
|
||||
// focus the filter
|
||||
filtr = this.findFilterComponent(key)
|
||||
this.$nextTick(() => {
|
||||
filtr.focusValue()
|
||||
})
|
||||
},
|
||||
|
||||
clearFilters() {
|
||||
|
||||
// explicitly deactivate all filters
|
||||
for (let filter of this.filters) {
|
||||
filter.active = false
|
||||
}
|
||||
|
||||
// then just "apply" as normal
|
||||
this.applyFilters()
|
||||
},
|
||||
|
||||
applyFilters(params) {
|
||||
if (params === undefined) {
|
||||
params = this.getFilterParams()
|
||||
}
|
||||
|
||||
// hide inactive filters
|
||||
for (let filter of this.filters) {
|
||||
if (!filter.active) {
|
||||
filter.visible = false
|
||||
}
|
||||
}
|
||||
|
||||
// fetch new data
|
||||
params.filter = true
|
||||
this.fetchData(params)
|
||||
},
|
||||
|
||||
getFilterParams() {
|
||||
const params = {}
|
||||
for (let filter of this.filters) {
|
||||
if (filter.active) {
|
||||
params[filter.key] = filter.value
|
||||
params[filter.key+'.verb'] = filter.verb
|
||||
}
|
||||
}
|
||||
if (Object.keys(params).length) {
|
||||
params.filter = 'true'
|
||||
}
|
||||
return params
|
||||
},
|
||||
|
||||
% endif
|
||||
|
||||
% if grid.sortable and grid.sort_on_backend:
|
||||
|
||||
onSort(field, order, event) {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
|
||||
<%def name="make_wutta_components()">
|
||||
${self.make_wutta_button_component()}
|
||||
${self.make_wutta_filter_component()}
|
||||
${self.make_wutta_filter_value_component()}
|
||||
</%def>
|
||||
|
||||
<%def name="make_wutta_button_component()">
|
||||
|
@ -69,3 +71,97 @@
|
|||
Vue.component('wutta-button', WuttaButton)
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
<%def name="make_wutta_filter_component()">
|
||||
<script type="text/x-template" id="wutta-filter-template">
|
||||
<div v-show="filter.visible"
|
||||
class="wutta-filter">
|
||||
|
||||
<b-button @click="filter.active = !filter.active"
|
||||
class="filter-toggle"
|
||||
icon-pack="fas"
|
||||
:icon-left="filter.active ? 'check' : null"
|
||||
:size="isSmall ? 'is-small' : null">
|
||||
{{ filter.label }}
|
||||
</b-button>
|
||||
|
||||
<div v-show="filter.active"
|
||||
style="display: flex; gap: 0.5rem;">
|
||||
|
||||
<b-select v-model="filter.verb"
|
||||
class="filter-verb"
|
||||
:size="isSmall ? 'is-small' : null">
|
||||
<option v-for="verb in filter.verbs"
|
||||
:key="verb"
|
||||
:value="verb">
|
||||
{{ verb }}
|
||||
</option>
|
||||
</b-select>
|
||||
|
||||
<wutta-filter-value v-model="filter.value"
|
||||
ref="filterValue"
|
||||
:is-small="isSmall" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script>
|
||||
|
||||
const WuttaFilter = {
|
||||
template: '#wutta-filter-template',
|
||||
props: {
|
||||
filter: Object,
|
||||
isSmall: Boolean,
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
focusValue: function() {
|
||||
this.$refs.filterValue.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vue.component('wutta-filter', WuttaFilter)
|
||||
|
||||
</script>
|
||||
</%def>
|
||||
|
||||
<%def name="make_wutta_filter_value_component()">
|
||||
<script type="text/x-template" id="wutta-filter-value-template">
|
||||
<div class="wutta-filter-value">
|
||||
|
||||
<b-input v-model="inputValue"
|
||||
ref="valueInput"
|
||||
@input="val => $emit('input', val)"
|
||||
:size="isSmall ? 'is-small' : null" />
|
||||
|
||||
</div>
|
||||
</script>
|
||||
<script>
|
||||
|
||||
const WuttaFilterValue = {
|
||||
template: '#wutta-filter-value-template',
|
||||
props: {
|
||||
value: String,
|
||||
isSmall: Boolean,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
inputValue: this.value,
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
focus: function() {
|
||||
this.$refs.valueInput.focus()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Vue.component('wutta-filter-value', WuttaFilterValue)
|
||||
|
||||
</script>
|
||||
</%def>
|
||||
|
|
|
@ -181,6 +181,23 @@ class MasterView(View):
|
|||
|
||||
This is optional; see also :meth:`get_grid_columns()`.
|
||||
|
||||
.. attribute:: filterable
|
||||
|
||||
Boolean indicating whether the grid for the :meth:`index()`
|
||||
view should allow filtering of data. Default is ``True``.
|
||||
|
||||
This is used by :meth:`make_model_grid()` to set the grid's
|
||||
:attr:`~wuttaweb.grids.base.Grid.filterable` flag.
|
||||
|
||||
.. attribute:: filter_defaults
|
||||
|
||||
Optional dict of default filter state.
|
||||
|
||||
This is used by :meth:`make_model_grid()` to set the grid's
|
||||
:attr:`~wuttaweb.grids.base.Grid.filter_defaults`.
|
||||
|
||||
Only relevant if :attr:`filterable` is true.
|
||||
|
||||
.. attribute:: sortable
|
||||
|
||||
Boolean indicating whether the grid for the :meth:`index()`
|
||||
|
@ -283,6 +300,8 @@ class MasterView(View):
|
|||
# features
|
||||
listable = True
|
||||
has_grid = True
|
||||
filterable = True
|
||||
filter_defaults = None
|
||||
sortable = True
|
||||
sort_on_backend = True
|
||||
sort_defaults = None
|
||||
|
@ -337,13 +356,26 @@ class MasterView(View):
|
|||
if self.has_grid:
|
||||
grid = self.make_model_grid()
|
||||
|
||||
# so-called 'partial' requests get just data, no html
|
||||
# handle "full" vs. "partial" differently
|
||||
if self.request.GET.get('partial'):
|
||||
|
||||
# so-called 'partial' requests get just data, no html
|
||||
context = {'data': grid.get_vue_data()}
|
||||
if grid.paginated and grid.paginate_on_backend:
|
||||
context['pager_stats'] = grid.get_vue_pager_stats()
|
||||
return self.json_response(context)
|
||||
|
||||
else: # full, not partial
|
||||
|
||||
# nb. when user asks to reset view, it is via the query
|
||||
# string. if so we then redirect to discard that.
|
||||
if self.request.GET.get('reset-view'):
|
||||
|
||||
# nb. we want to preserve url hash if applicable
|
||||
kw = {'_query': None,
|
||||
'_anchor': self.request.GET.get('hash')}
|
||||
return self.redirect(self.request.current_route_url(**kw))
|
||||
|
||||
context['grid'] = grid
|
||||
|
||||
return self.render_to_response('index', context)
|
||||
|
@ -1208,6 +1240,8 @@ class MasterView(View):
|
|||
|
||||
kwargs['actions'] = actions
|
||||
|
||||
kwargs.setdefault('filterable', self.filterable)
|
||||
kwargs.setdefault('filter_defaults', self.filter_defaults)
|
||||
kwargs.setdefault('sortable', self.sortable)
|
||||
kwargs.setdefault('sort_multiple', not self.request.use_oruga)
|
||||
kwargs.setdefault('sort_on_backend', self.sort_on_backend)
|
||||
|
|
|
@ -58,6 +58,10 @@ class PersonView(MasterView):
|
|||
'last_name',
|
||||
]
|
||||
|
||||
filter_defaults = {
|
||||
'full_name': {'active': True},
|
||||
}
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
|
|
|
@ -52,6 +52,10 @@ class RoleView(MasterView):
|
|||
'notes',
|
||||
]
|
||||
|
||||
filter_defaults = {
|
||||
'name': {'active': True},
|
||||
}
|
||||
|
||||
# TODO: master should handle this, possibly via configure_form()
|
||||
def get_query(self, session=None):
|
||||
""" """
|
||||
|
|
|
@ -201,6 +201,9 @@ class SettingView(MasterView):
|
|||
"""
|
||||
model_class = Setting
|
||||
model_title = "Raw Setting"
|
||||
filter_defaults = {
|
||||
'name': {'active': True},
|
||||
}
|
||||
sort_defaults = 'name'
|
||||
|
||||
# TODO: master should handle this (per model key)
|
||||
|
|
|
@ -55,6 +55,10 @@ class UserView(MasterView):
|
|||
'active',
|
||||
]
|
||||
|
||||
filter_defaults = {
|
||||
'username': {'active': True},
|
||||
}
|
||||
|
||||
# TODO: master should handle this, possibly via configure_form()
|
||||
def get_query(self, session=None):
|
||||
""" """
|
||||
|
|
|
@ -383,8 +383,7 @@ class TestGrid(WebTestCase):
|
|||
grid = self.make_grid(key='settings', model_class=model.Setting,
|
||||
filterable=True)
|
||||
self.assertEqual(len(grid.filters), 2)
|
||||
self.assertFalse(hasattr(grid.filters['name'], 'active'))
|
||||
self.assertFalse(hasattr(grid.filters['value'], 'active'))
|
||||
self.assertEqual(len(grid.active_filters), 0)
|
||||
self.assertNotIn('grid.settings.filter.name.active', self.request.session)
|
||||
self.assertNotIn('grid.settings.filter.value.active', self.request.session)
|
||||
self.request.GET = {'name': 'john', 'name.verb': 'contains'}
|
||||
|
@ -401,8 +400,7 @@ class TestGrid(WebTestCase):
|
|||
grid = self.make_grid(key='settings', model_class=model.Setting,
|
||||
sortable=True, filterable=True)
|
||||
self.assertEqual(len(grid.filters), 2)
|
||||
self.assertFalse(hasattr(grid.filters['name'], 'active'))
|
||||
self.assertFalse(hasattr(grid.filters['value'], 'active'))
|
||||
self.assertEqual(len(grid.active_filters), 0)
|
||||
self.assertNotIn('grid.settings.filter.name.active', self.request.session)
|
||||
self.assertNotIn('grid.settings.filter.value.active', self.request.session)
|
||||
self.assertNotIn('grid.settings.sorters.length', self.request.session)
|
||||
|
@ -419,6 +417,12 @@ class TestGrid(WebTestCase):
|
|||
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
|
||||
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'asc')
|
||||
|
||||
# can reset view to defaults
|
||||
self.request.GET = {'reset-view': 'true'}
|
||||
grid.load_settings()
|
||||
self.assertEqual(grid.active_filters, [])
|
||||
self.assertIsNone(grid.filters['name'].value)
|
||||
|
||||
def test_request_has_settings(self):
|
||||
model = self.app.model
|
||||
grid = self.make_grid(key='settings', model_class=model.Setting)
|
||||
|
@ -927,6 +931,10 @@ class TestGrid(WebTestCase):
|
|||
filtr = grid.make_filter(model.Setting.name)
|
||||
self.assertIsInstance(filtr, mod.GridFilter)
|
||||
|
||||
# invalid model class
|
||||
grid = self.make_grid(model_class=42)
|
||||
self.assertRaises(ValueError, grid.make_filter, 'name')
|
||||
|
||||
def test_set_filter(self):
|
||||
model = self.app.model
|
||||
|
||||
|
@ -968,6 +976,22 @@ class TestGrid(WebTestCase):
|
|||
grid.remove_filter('value')
|
||||
self.assertNotIn('value', grid.filters)
|
||||
|
||||
def test_set_filter_defaults(self):
|
||||
model = self.app.model
|
||||
|
||||
# empty by default
|
||||
grid = self.make_grid(model_class=model.Setting, filterable=True)
|
||||
self.assertEqual(grid.filter_defaults, {})
|
||||
|
||||
# can specify via method call
|
||||
grid.set_filter_defaults(name={'active': True})
|
||||
self.assertEqual(grid.filter_defaults, {'name': {'active': True}})
|
||||
|
||||
# can specify via constructor
|
||||
grid = self.make_grid(model_class=model.Setting, filterable=True,
|
||||
filter_defaults={'name': {'active': True}})
|
||||
self.assertEqual(grid.filter_defaults, {'name': {'active': True}})
|
||||
|
||||
##############################
|
||||
# data methods
|
||||
##############################
|
||||
|
@ -1008,11 +1032,82 @@ class TestGrid(WebTestCase):
|
|||
|
||||
def test_filter_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)
|
||||
|
||||
query = self.session.query(model.Setting)
|
||||
grid = self.make_grid(model_class=model.Setting, filterable=True)
|
||||
grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
|
||||
|
||||
# not filtered by default
|
||||
grid.load_settings()
|
||||
self.assertRaises(NotImplementedError, grid.filter_data, query)
|
||||
self.assertEqual(grid.active_filters, [])
|
||||
filtered_query = grid.filter_data(sample_query)
|
||||
self.assertIs(filtered_query, sample_query)
|
||||
|
||||
# can be filtered per session settings
|
||||
self.request.session['grid.settings.filter.value.active'] = True
|
||||
self.request.session['grid.settings.filter.value.verb'] = 'contains'
|
||||
self.request.session['grid.settings.filter.value.value'] = 'ggg'
|
||||
grid.load_settings()
|
||||
self.assertEqual(len(grid.active_filters), 1)
|
||||
self.assertEqual(grid.active_filters[0].key, 'value')
|
||||
filtered_query = grid.filter_data(sample_query)
|
||||
self.assertIsInstance(filtered_query, orm.Query)
|
||||
self.assertIsNot(filtered_query, sample_query)
|
||||
self.assertEqual(filtered_query.count(), 3)
|
||||
|
||||
# can be filtered per request settings
|
||||
self.request.GET = {'value': 's', 'value.verb': 'contains'}
|
||||
grid.load_settings()
|
||||
self.assertEqual(len(grid.active_filters), 1)
|
||||
self.assertEqual(grid.active_filters[0].key, 'value')
|
||||
filtered_query = grid.filter_data(sample_query)
|
||||
self.assertIsInstance(filtered_query, orm.Query)
|
||||
self.assertEqual(filtered_query.count(), 2)
|
||||
|
||||
# not filtered if verb is invalid
|
||||
self.request.GET = {'value': 'ggg', 'value.verb': 'doesnotexist'}
|
||||
grid.load_settings()
|
||||
self.assertEqual(len(grid.active_filters), 1)
|
||||
self.assertEqual(grid.active_filters[0].verb, 'doesnotexist')
|
||||
filtered_query = grid.filter_data(sample_query)
|
||||
self.assertIs(filtered_query, sample_query)
|
||||
self.assertEqual(filtered_query.count(), 9)
|
||||
|
||||
# not filtered if error
|
||||
self.request.GET = {'value': 'ggg', 'value.verb': 'contains'}
|
||||
grid.load_settings()
|
||||
self.assertEqual(len(grid.active_filters), 1)
|
||||
self.assertEqual(grid.active_filters[0].verb, 'contains')
|
||||
filtered_query = grid.filter_data(sample_query)
|
||||
self.assertIsNot(filtered_query, sample_query)
|
||||
self.assertEqual(filtered_query.count(), 3)
|
||||
with patch.object(grid.active_filters[0], 'filter_contains', side_effect=RuntimeError):
|
||||
filtered_query = grid.filter_data(sample_query)
|
||||
self.assertIs(filtered_query, sample_query)
|
||||
self.assertEqual(filtered_query.count(), 9)
|
||||
|
||||
# joiner is invoked
|
||||
self.assertEqual(len(grid.active_filters), 1)
|
||||
self.assertEqual(grid.active_filters[0].key, 'value')
|
||||
joiner = MagicMock(side_effect=lambda q: q)
|
||||
grid.joiners = {'value': joiner}
|
||||
grid.joined = set()
|
||||
filtered_query = grid.filter_data(sample_query)
|
||||
joiner.assert_called_once_with(sample_query)
|
||||
self.assertEqual(filtered_query.count(), 3)
|
||||
|
||||
def test_sort_data(self):
|
||||
model = self.app.model
|
||||
|
@ -1210,6 +1305,15 @@ class TestGrid(WebTestCase):
|
|||
sorters = grid.get_vue_active_sorters()
|
||||
self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}])
|
||||
|
||||
def test_get_vue_filters(self):
|
||||
model = self.app.model
|
||||
|
||||
# basic
|
||||
grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
|
||||
grid.load_settings()
|
||||
filters = grid.get_vue_filters()
|
||||
self.assertEqual(len(filters), 2)
|
||||
|
||||
def test_get_vue_data(self):
|
||||
|
||||
# empty if no columns defined
|
||||
|
@ -1317,3 +1421,86 @@ class TestGridAction(TestCase):
|
|||
action = self.make_action('blarg', url=lambda o, i: '/yeehaw')
|
||||
url = action.get_url(obj)
|
||||
self.assertEqual(url, '/yeehaw')
|
||||
|
||||
|
||||
class TestGridFilter(WebTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.setup_web()
|
||||
|
||||
model = self.app.model
|
||||
self.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 self.sample_data:
|
||||
self.app.save_setting(self.session, setting['name'], setting['value'])
|
||||
self.session.commit()
|
||||
self.sample_query = self.session.query(model.Setting)
|
||||
|
||||
def make_filter(self, model_property, **kwargs):
|
||||
return mod.GridFilter(self.request, model_property, **kwargs)
|
||||
|
||||
def test_repr(self):
|
||||
model = self.app.model
|
||||
filtr = self.make_filter(model.Setting.name)
|
||||
self.assertEqual(repr(filtr), "GridFilter(key='name', active=False, verb='contains', value=None)")
|
||||
|
||||
def test_apply_filter(self):
|
||||
model = self.app.model
|
||||
filtr = self.make_filter(model.Setting.value)
|
||||
|
||||
# default verb used as fallback
|
||||
self.assertEqual(filtr.default_verb, 'contains')
|
||||
filtr.verb = None
|
||||
with patch.object(filtr, 'filter_contains', side_effect=lambda q, v: q) as filter_contains:
|
||||
filtered_query = filtr.apply_filter(self.sample_query, value='foo')
|
||||
filter_contains.assert_called_once_with(self.sample_query, 'foo')
|
||||
self.assertIsNone(filtr.verb)
|
||||
|
||||
# filter verb used as fallback
|
||||
filtr.verb = 'equal'
|
||||
with patch.object(filtr, 'filter_equal', create=True, side_effect=lambda q, v: q) as filter_equal:
|
||||
filtered_query = filtr.apply_filter(self.sample_query, value='foo')
|
||||
filter_equal.assert_called_once_with(self.sample_query, 'foo')
|
||||
|
||||
# filter value used as fallback
|
||||
filtr.verb = 'contains'
|
||||
filtr.value = 'blarg'
|
||||
with patch.object(filtr, 'filter_contains', side_effect=lambda q, v: q) as filter_contains:
|
||||
filtered_query = filtr.apply_filter(self.sample_query)
|
||||
filter_contains.assert_called_once_with(self.sample_query, 'blarg')
|
||||
|
||||
# error if invalid verb
|
||||
self.assertRaises(mod.VerbNotSupported, filtr.apply_filter,
|
||||
self.sample_query, verb='doesnotexist')
|
||||
|
||||
def test_filter_contains(self):
|
||||
model = self.app.model
|
||||
filtr = self.make_filter(model.Setting.value)
|
||||
self.assertEqual(self.sample_query.count(), 9)
|
||||
|
||||
# not filtered for empty value
|
||||
filtered_query = filtr.filter_contains(self.sample_query, None)
|
||||
self.assertIs(filtered_query, self.sample_query)
|
||||
filtered_query = filtr.filter_contains(self.sample_query, '')
|
||||
self.assertIs(filtered_query, self.sample_query)
|
||||
|
||||
# filtered by value
|
||||
filtered_query = filtr.filter_contains(self.sample_query, 'ggg')
|
||||
self.assertIsNot(filtered_query, self.sample_query)
|
||||
self.assertEqual(filtered_query.count(), 3)
|
||||
|
||||
|
||||
class TestVerbNotSupported(TestCase):
|
||||
|
||||
def test_basic(self):
|
||||
error = mod.VerbNotSupported('equal')
|
||||
self.assertEqual(str(error), "unknown filter verb not supported: equal")
|
||||
|
|
|
@ -761,6 +761,12 @@ class TestMasterView(WebTestCase):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.content_type, 'application/json')
|
||||
|
||||
# redirects when view is reset
|
||||
self.request.GET = {'reset-view': '1', 'hash': 'foo'}
|
||||
with patch.object(self.request, 'current_route_url'):
|
||||
response = view.index()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_create(self):
|
||||
self.pyramid_config.include('wuttaweb.views.common')
|
||||
self.pyramid_config.include('wuttaweb.views.auth')
|
||||
|
|
Loading…
Reference in a new issue