2
0
Fork 0

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:
Lance Edgar 2024-08-22 13:50:29 -05:00
parent 9751bf4c2e
commit 1443f5253f
12 changed files with 1060 additions and 223 deletions

View file

@ -39,6 +39,7 @@ from webhelpers2.html import HTML
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttaweb.util import FieldList, get_model_fields, make_json_safe from wuttaweb.util import FieldList, get_model_fields, make_json_safe
from wuttjamaican.util import UNSPECIFIED
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -304,6 +305,12 @@ class Grid:
See also :meth:`set_filter()`. See also :meth:`set_filter()`.
.. attribute:: filter_defaults
Dict containing default state preferences for the filters.
See also :meth:`set_filter_defaults()`.
.. attribute:: joiners .. attribute:: joiners
Dict of "joiner" functions for use with backend filtering and Dict of "joiner" functions for use with backend filtering and
@ -337,6 +344,7 @@ class Grid:
searchable_columns=None, searchable_columns=None,
filterable=False, filterable=False,
filters=None, filters=None,
filter_defaults=None,
joiners=None, joiners=None,
): ):
self.request = request self.request = request
@ -388,6 +396,7 @@ class Grid:
self.filters = self.make_backend_filters() self.filters = self.make_backend_filters()
else: else:
self.filters = {} self.filters = {}
self.set_filter_defaults(**(filter_defaults or {}))
def get_columns(self): def get_columns(self):
""" """
@ -1025,14 +1034,8 @@ class Grid:
def make_filter(self, columninfo, **kwargs): def make_filter(self, columninfo, **kwargs):
""" """
Creates and returns a Create and return a :class:`GridFilter` instance suitable for
:class:`~wuttaweb.grids.filters.GridFilter` instance suitable use on the given column.
for use as a backend filter on the given column.
.. warning::
This method is not yet implemented; subclass *must*
override.
Code usually does not need to call this directly. See also Code usually does not need to call this directly. See also
:meth:`set_filter()`, which calls this method automatically. :meth:`set_filter()`, which calls this method automatically.
@ -1040,16 +1043,24 @@ class Grid:
:param columninfo: Can be either a model property (see below), :param columninfo: Can be either a model property (see below),
or a column name. or a column name.
:returns: A :class:`~wuttaweb.grids.filters.GridFilter` :returns: A :class:`GridFilter` instance.
instance suitable for backend sorting.
""" """
model_property = None
if isinstance(columninfo, str): if isinstance(columninfo, str):
key = columninfo 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: else:
model_property = columninfo 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): def set_filter(self, key, filterinfo=None, **kwargs):
""" """
@ -1096,6 +1107,31 @@ class Grid:
""" """
self.filters.pop(key, None) 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 # paging methods
############################## ##############################
@ -1188,9 +1224,13 @@ class Grid:
settings = {} settings = {}
if self.filterable: if self.filterable:
for filtr in self.filters.values(): for filtr in self.filters.values():
settings[f'filter.{filtr.key}.active'] = filtr.default_active defaults = self.filter_defaults.get(filtr.key, {})
settings[f'filter.{filtr.key}.verb'] = filtr.default_verb settings[f'filter.{filtr.key}.active'] = defaults.get('active',
settings[f'filter.{filtr.key}.value'] = filtr.default_value 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.sortable:
if self.sort_defaults: if self.sort_defaults:
# nb. as of writing neither Buefy nor Oruga support a # nb. as of writing neither Buefy nor Oruga support a
@ -1205,21 +1245,16 @@ class Grid:
settings['pagesize'] = self.pagesize settings['pagesize'] = self.pagesize
settings['page'] = self.page 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 # update settings dict based on what we find in the request
# and/or user session. always prioritize the former. # 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') self.update_filter_settings(settings, src='request')
if self.request_has_settings('sort'): if self.request_has_settings('sort'):
self.update_sort_settings(settings, src='request') self.update_sort_settings(settings, src='request')
@ -1250,19 +1285,13 @@ class Grid:
if persist: if persist:
self.persist_settings(settings, dest='session') 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.. # update ourself to reflect settings dict..
# filtering # filtering
if self.filterable: if self.filterable:
for filtr in self.filters.values(): for filtr in self.filters.values():
filtr.active = settings[f'filter.{filtr.key}.active'] 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'] filtr.value = settings[f'filter.{filtr.key}.value']
# sorting # sorting
@ -1497,20 +1526,45 @@ class Grid:
return data 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): def filter_data(self, data, filters=None):
""" """
Filter the given data and return the result. This is called Filter the given data and return the result. This is called
by :meth:`get_visible_data()`. by :meth:`get_visible_data()`.
:param filters: Optional list of filters to use. If not :param filters: Optional list of filters to use. If not
specified, the grid's "active" filters are used. specified, the grid's :attr:`active_filters` are used.
.. warning::
This method is not yet implemented; subclass *must*
override.
""" """
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): def sort_data(self, data, sorters=None):
""" """
@ -1588,6 +1642,58 @@ class Grid:
# rendering methods # 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): def render_vue_tag(self, **kwargs):
""" """
Render the Vue component tag for the grid. Render the Vue component tag for the grid.
@ -1649,58 +1755,6 @@ class Grid:
output = render(template, context) output = render(template, context)
return HTML.literal(output) 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): def render_vue_finalize(self):
""" """
Render the Vue "finalize" script for the grid. Render the Vue "finalize" script for the grid.
@ -1781,6 +1835,26 @@ class Grid:
'order': sorter['dir']}) 'order': sorter['dir']})
return sorters 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): def get_vue_data(self):
""" """
Returns a list of Vue-compatible data records. Returns a list of Vue-compatible data records.
@ -2009,24 +2083,162 @@ class GridAction:
return self.url return self.url
# TODO: this needs plenty of work yet..and probably will move?
class GridFilter: 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__( def __init__(
self, self,
request, request,
key, model_property,
label=None,
default_active=False, default_active=False,
default_verb=None, default_verb=None,
default_value=None, default_value=None,
**kwargs, **kwargs,
): ):
self.request = request 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_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.default_value = default_value
self.value = self.default_value
self.__dict__.update(kwargs) 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}"

View file

@ -151,6 +151,33 @@
white-space: nowrap; 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 { .wutta-form-wrapper {
margin-left: 5rem; margin-left: 5rem;
margin-top: 2rem; margin-top: 2rem;

View file

@ -1,6 +1,90 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<script type="text/x-template" id="${grid.vue_tagname}-template"> <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" <${b}-table :data="data"
:loading="loading" :loading="loading"
narrowed narrowed
@ -125,6 +209,7 @@
% endif % endif
</${b}-table> </${b}-table>
</div>
</script> </script>
<script> <script>
@ -138,6 +223,14 @@
## nb. this tracks whether grid.fetchFirstData() happened ## nb. this tracks whether grid.fetchFirstData() happened
fetchedFirstData: false, fetchedFirstData: false,
## filtering
% if grid.filterable:
filters: ${json.dumps(grid.get_vue_filters())|n},
addFilterShow: false,
addFilterTerm: '',
smallFilters: false,
% endif
## sorting ## sorting
% if grid.sortable: % if grid.sortable:
sorters: ${json.dumps(grid.get_vue_active_sorters())|n}, sorters: ${json.dumps(grid.get_vue_active_sorters())|n},
@ -175,6 +268,47 @@
template: '#${grid.vue_tagname}-template', template: '#${grid.vue_tagname}-template',
computed: { 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: % if not grid.paginate_on_backend:
pagerStats() { pagerStats() {
@ -245,9 +379,12 @@
this.fetchedFirstData = true 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')) { if (!params.has('partial')) {
params.append('partial', true) 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: % if grid.sortable and grid.sort_on_backend:
onSort(field, order, event) { onSort(field, order, event) {

View file

@ -1,6 +1,8 @@
<%def name="make_wutta_components()"> <%def name="make_wutta_components()">
${self.make_wutta_button_component()} ${self.make_wutta_button_component()}
${self.make_wutta_filter_component()}
${self.make_wutta_filter_value_component()}
</%def> </%def>
<%def name="make_wutta_button_component()"> <%def name="make_wutta_button_component()">
@ -69,3 +71,97 @@
Vue.component('wutta-button', WuttaButton) Vue.component('wutta-button', WuttaButton)
</script> </script>
</%def> </%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>

View file

@ -181,6 +181,23 @@ class MasterView(View):
This is optional; see also :meth:`get_grid_columns()`. 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 .. attribute:: sortable
Boolean indicating whether the grid for the :meth:`index()` Boolean indicating whether the grid for the :meth:`index()`
@ -283,6 +300,8 @@ class MasterView(View):
# features # features
listable = True listable = True
has_grid = True has_grid = True
filterable = True
filter_defaults = None
sortable = True sortable = True
sort_on_backend = True sort_on_backend = True
sort_defaults = None sort_defaults = None
@ -337,13 +356,26 @@ class MasterView(View):
if self.has_grid: if self.has_grid:
grid = self.make_model_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'): if self.request.GET.get('partial'):
# so-called 'partial' requests get just data, no html
context = {'data': grid.get_vue_data()} context = {'data': grid.get_vue_data()}
if grid.paginated and grid.paginate_on_backend: if grid.paginated and grid.paginate_on_backend:
context['pager_stats'] = grid.get_vue_pager_stats() context['pager_stats'] = grid.get_vue_pager_stats()
return self.json_response(context) 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 context['grid'] = grid
return self.render_to_response('index', context) return self.render_to_response('index', context)
@ -1208,6 +1240,8 @@ class MasterView(View):
kwargs['actions'] = actions kwargs['actions'] = actions
kwargs.setdefault('filterable', self.filterable)
kwargs.setdefault('filter_defaults', self.filter_defaults)
kwargs.setdefault('sortable', self.sortable) kwargs.setdefault('sortable', self.sortable)
kwargs.setdefault('sort_multiple', not self.request.use_oruga) kwargs.setdefault('sort_multiple', not self.request.use_oruga)
kwargs.setdefault('sort_on_backend', self.sort_on_backend) kwargs.setdefault('sort_on_backend', self.sort_on_backend)

View file

@ -58,6 +58,10 @@ class PersonView(MasterView):
'last_name', 'last_name',
] ]
filter_defaults = {
'full_name': {'active': True},
}
def configure_grid(self, g): def configure_grid(self, g):
""" """ """ """
super().configure_grid(g) super().configure_grid(g)

View file

@ -52,6 +52,10 @@ class RoleView(MasterView):
'notes', 'notes',
] ]
filter_defaults = {
'name': {'active': True},
}
# TODO: master should handle this, possibly via configure_form() # TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None): def get_query(self, session=None):
""" """ """ """

View file

@ -201,6 +201,9 @@ class SettingView(MasterView):
""" """
model_class = Setting model_class = Setting
model_title = "Raw Setting" model_title = "Raw Setting"
filter_defaults = {
'name': {'active': True},
}
sort_defaults = 'name' sort_defaults = 'name'
# TODO: master should handle this (per model key) # TODO: master should handle this (per model key)

View file

@ -55,6 +55,10 @@ class UserView(MasterView):
'active', 'active',
] ]
filter_defaults = {
'username': {'active': True},
}
# TODO: master should handle this, possibly via configure_form() # TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None): def get_query(self, session=None):
""" """ """ """

View file

@ -383,8 +383,7 @@ class TestGrid(WebTestCase):
grid = self.make_grid(key='settings', model_class=model.Setting, grid = self.make_grid(key='settings', model_class=model.Setting,
filterable=True) filterable=True)
self.assertEqual(len(grid.filters), 2) self.assertEqual(len(grid.filters), 2)
self.assertFalse(hasattr(grid.filters['name'], 'active')) self.assertEqual(len(grid.active_filters), 0)
self.assertFalse(hasattr(grid.filters['value'], 'active'))
self.assertNotIn('grid.settings.filter.name.active', self.request.session) self.assertNotIn('grid.settings.filter.name.active', self.request.session)
self.assertNotIn('grid.settings.filter.value.active', self.request.session) self.assertNotIn('grid.settings.filter.value.active', self.request.session)
self.request.GET = {'name': 'john', 'name.verb': 'contains'} 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, grid = self.make_grid(key='settings', model_class=model.Setting,
sortable=True, filterable=True) sortable=True, filterable=True)
self.assertEqual(len(grid.filters), 2) self.assertEqual(len(grid.filters), 2)
self.assertFalse(hasattr(grid.filters['name'], 'active')) self.assertEqual(len(grid.active_filters), 0)
self.assertFalse(hasattr(grid.filters['value'], 'active'))
self.assertNotIn('grid.settings.filter.name.active', self.request.session) self.assertNotIn('grid.settings.filter.name.active', self.request.session)
self.assertNotIn('grid.settings.filter.value.active', self.request.session) self.assertNotIn('grid.settings.filter.value.active', self.request.session)
self.assertNotIn('grid.settings.sorters.length', 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.key'], 'name')
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'asc') 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): def test_request_has_settings(self):
model = self.app.model model = self.app.model
grid = self.make_grid(key='settings', model_class=model.Setting) grid = self.make_grid(key='settings', model_class=model.Setting)
@ -927,6 +931,10 @@ class TestGrid(WebTestCase):
filtr = grid.make_filter(model.Setting.name) filtr = grid.make_filter(model.Setting.name)
self.assertIsInstance(filtr, mod.GridFilter) 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): def test_set_filter(self):
model = self.app.model model = self.app.model
@ -968,6 +976,22 @@ class TestGrid(WebTestCase):
grid.remove_filter('value') grid.remove_filter('value')
self.assertNotIn('value', grid.filters) 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 # data methods
############################## ##############################
@ -1008,11 +1032,82 @@ class TestGrid(WebTestCase):
def test_filter_data(self): def test_filter_data(self):
model = self.app.model 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(key='settings', model_class=model.Setting, filterable=True)
grid = self.make_grid(model_class=model.Setting, filterable=True)
# not filtered by default
grid.load_settings() 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): def test_sort_data(self):
model = self.app.model model = self.app.model
@ -1210,6 +1305,15 @@ class TestGrid(WebTestCase):
sorters = grid.get_vue_active_sorters() sorters = grid.get_vue_active_sorters()
self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}]) 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): def test_get_vue_data(self):
# empty if no columns defined # empty if no columns defined
@ -1317,3 +1421,86 @@ class TestGridAction(TestCase):
action = self.make_action('blarg', url=lambda o, i: '/yeehaw') action = self.make_action('blarg', url=lambda o, i: '/yeehaw')
url = action.get_url(obj) url = action.get_url(obj)
self.assertEqual(url, '/yeehaw') 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")

View file

@ -761,6 +761,12 @@ class TestMasterView(WebTestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.content_type, 'application/json') 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): def test_create(self):
self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth') self.pyramid_config.include('wuttaweb.views.auth')