From 122c6650d542c9f109b56134655d639ac3f2cefa Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 16:42:42 -0500
Subject: [PATCH 1/7] fix: add override hook for base form template

---
 src/wuttaweb/templates/form.mako | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/src/wuttaweb/templates/form.mako b/src/wuttaweb/templates/form.mako
index 7029463..de7209a 100644
--- a/src/wuttaweb/templates/form.mako
+++ b/src/wuttaweb/templates/form.mako
@@ -9,13 +9,17 @@
   % endif
 </%def>
 
-<%def name="render_vue_templates()">
-  ${parent.render_vue_templates()}
+<%def name="render_vue_template_form()">
   % if form is not Undefined:
       ${form.render_vue_template()}
   % endif
 </%def>
 
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${self.render_vue_template_form()}
+</%def>
+
 <%def name="make_vue_components()">
   ${parent.make_vue_components()}
   % if form is not Undefined:

From db5e4e88f632d3e6728806863a7f2820ab22208d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 19:38:33 -0500
Subject: [PATCH 2/7] fix: set sort defaults for users, roles

---
 src/wuttaweb/views/roles.py | 1 +
 src/wuttaweb/views/users.py | 1 +
 2 files changed, 2 insertions(+)

diff --git a/src/wuttaweb/views/roles.py b/src/wuttaweb/views/roles.py
index fa7c8fc..bf4981d 100644
--- a/src/wuttaweb/views/roles.py
+++ b/src/wuttaweb/views/roles.py
@@ -55,6 +55,7 @@ class RoleView(MasterView):
     filter_defaults = {
         'name': {'active': True},
     }
+    sort_defaults = 'name'
 
     # TODO: master should handle this, possibly via configure_form()
     def get_query(self, session=None):
diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py
index d05b8eb..9c25dc3 100644
--- a/src/wuttaweb/views/users.py
+++ b/src/wuttaweb/views/users.py
@@ -58,6 +58,7 @@ class UserView(MasterView):
     filter_defaults = {
         'username': {'active': True},
     }
+    sort_defaults = 'username'
 
     # TODO: master should handle this, possibly via configure_form()
     def get_query(self, session=None):

From 2e5feeb6f43d4eafafd69e8bbc0c0eff288c6f84 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 19:43:42 -0500
Subject: [PATCH 3/7] fix: add once-button action for grid Reset View

---
 src/wuttaweb/templates/grids/vue_template.mako | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako
index 84dcb58..6ae5c5a 100644
--- a/src/wuttaweb/templates/grids/vue_template.mako
+++ b/src/wuttaweb/templates/grids/vue_template.mako
@@ -55,10 +55,11 @@
                                 :size="smallFilters ? 'is-small' : null" />
 
                 <b-button @click="resetView()"
+                          :disabled="viewResetting"
                           icon-pack="fas"
                           icon-left="undo"
                           :size="smallFilters ? 'is-small' : null">
-                  Reset View
+                  {{ viewResetting ? "Working, please wait..." : "Reset View" }}
                 </b-button>
 
                 <b-button v-show="activeFilters"
@@ -247,6 +248,7 @@
           addFilterShow: false,
           addFilterTerm: '',
           smallFilters: false,
+          viewResetting: false,
       % endif
 
       ## sorting
@@ -480,6 +482,7 @@
           },
 
           resetView() {
+              this.viewResetting = true
               this.loading = true
 
               // use current url proper, plus reset param

From 4525f91c21a97c86edfc6a91a8618d844ae87538 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 20:00:07 -0500
Subject: [PATCH 4/7] fix: small cleanup for grid filters template

---
 src/wuttaweb/templates/grids/vue_template.mako | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako
index 6ae5c5a..e701a4a 100644
--- a/src/wuttaweb/templates/grids/vue_template.mako
+++ b/src/wuttaweb/templates/grids/vue_template.mako
@@ -5,8 +5,7 @@
     <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()">
+          <form @submit.prevent="applyFilters()">
 
             <div style="display: flex; flex-direction: column; gap: 0.5rem;">
               <wutta-filter v-for="filtr in filters"

From f6fb6957e327c8f77b69755936bc83a3c322a929 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 23 Aug 2024 12:10:51 -0500
Subject: [PATCH 5/7] feat: improve grid filter API a bit, support string/bool
 filters

---
 docs/api/wuttaweb/grids.filters.rst          |   6 +
 docs/api/wuttaweb/index.rst                  |   1 +
 src/wuttaweb/grids/base.py                   | 207 ++-------
 src/wuttaweb/grids/filters.py                | 444 +++++++++++++++++++
 src/wuttaweb/templates/wutta-components.mako |  25 +-
 tests/grids/test_base.py                     | 122 ++---
 tests/grids/test_filters.py                  | 385 ++++++++++++++++
 7 files changed, 919 insertions(+), 271 deletions(-)
 create mode 100644 docs/api/wuttaweb/grids.filters.rst
 create mode 100644 src/wuttaweb/grids/filters.py
 create mode 100644 tests/grids/test_filters.py

diff --git a/docs/api/wuttaweb/grids.filters.rst b/docs/api/wuttaweb/grids.filters.rst
new file mode 100644
index 0000000..b929d75
--- /dev/null
+++ b/docs/api/wuttaweb/grids.filters.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.grids.filters``
+==========================
+
+.. automodule:: wuttaweb.grids.filters
+   :members:
diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 1410a20..9749cae 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -16,6 +16,7 @@
    forms.widgets
    grids
    grids.base
+   grids.filters
    handler
    helpers
    menus
diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py
index 3e7695c..0ec15f3 100644
--- a/src/wuttaweb/grids/base.py
+++ b/src/wuttaweb/grids/base.py
@@ -40,6 +40,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
+from wuttaweb.grids.filters import default_sqlalchemy_filters, VerbNotSupported
 
 
 log = logging.getLogger(__name__)
@@ -1034,8 +1035,9 @@ class Grid:
 
     def make_filter(self, columninfo, **kwargs):
         """
-        Create and return a :class:`GridFilter` instance suitable for
-        use on the given column.
+        Create and return a
+        :class:`~wuttaweb.grids.filters.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.
@@ -1043,24 +1045,34 @@ class Grid:
         :param columninfo: Can be either a model property (see below),
            or a column name.
 
-        :returns: A :class:`GridFilter` instance.
+        :returns: A :class:`~wuttaweb.grids.filters.GridFilter`
+           instance.
         """
+
+        # model_property is required
         model_property = None
-        if isinstance(columninfo, str):
+        if kwargs.get('model_property'):
+            model_property = kwargs['model_property']
+        elif 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)
+                model_property = getattr(self.model_class, key, None)
             if not model_property:
                 raise ValueError(f"cannot locate model property for key: {key}")
         else:
             model_property = columninfo
 
-        return GridFilter(self.request, model_property, **kwargs)
+        # optional factory override
+        factory = kwargs.pop('factory', None)
+        if not factory:
+            typ = model_property.type
+            factory = default_sqlalchemy_filters.get(type(typ))
+            if not factory:
+                factory = default_sqlalchemy_filters[None]
+
+        # make filter
+        kwargs['model_property'] = model_property
+        return factory(self.request, model_property.key, **kwargs)
 
     def set_filter(self, key, filterinfo=None, **kwargs):
         """
@@ -1228,7 +1240,7 @@ class Grid:
                 settings[f'filter.{filtr.key}.active'] = defaults.get('active',
                                                                       filtr.default_active)
                 settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
-                                                                    filtr.default_verb)
+                                                                    filtr.get_default_verb())
                 settings[f'filter.{filtr.key}.value'] = defaults.get('value',
                                                                      filtr.default_value)
         if self.sortable:
@@ -1291,7 +1303,7 @@ class Grid:
         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'] or filtr.default_verb
+                filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.get_default_verb()
                 filtr.value = settings[f'filter.{filtr.key}.value']
 
         # sorting
@@ -1531,8 +1543,8 @@ class Grid:
         """
         Returns the list of currently active filters.
 
-        This inspects each :class:`GridFilter` in :attr:`filters` and
-        only returns the ones marked active.
+        This inspects each :class:`~wuttaweb.grids.filters.GridFilter`
+        in :attr:`filters` and only returns the ones marked active.
         """
         return [filtr for filtr in self.filters.values()
                 if filtr.active]
@@ -1848,7 +1860,9 @@ class Grid:
                 'key': filtr.key,
                 'active': filtr.active,
                 'visible': filtr.active,
-                'verbs': filtr.verbs,
+                'verbs': filtr.get_verbs(),
+                'verb_labels': filtr.get_verb_labels(),
+                'valueless_verbs': filtr.get_valueless_verbs(),
                 'verb': filtr.verb,
                 'value': filtr.value,
                 'label': filtr.label,
@@ -2081,164 +2095,3 @@ class GridAction:
             return self.url(obj, i)
 
         return self.url
-
-
-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,
-            model_property,
-            label=None,
-            default_active=False,
-            default_verb=None,
-            default_value=None,
-            **kwargs,
-    ):
-        self.request = request
-        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.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}"
diff --git a/src/wuttaweb/grids/filters.py b/src/wuttaweb/grids/filters.py
new file mode 100644
index 0000000..0489c22
--- /dev/null
+++ b/src/wuttaweb/grids/filters.py
@@ -0,0 +1,444 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  wuttaweb -- Web App for Wutta Framework
+#  Copyright © 2024 Lance Edgar
+#
+#  This file is part of Wutta Framework.
+#
+#  Wutta Framework is free software: you can redistribute it and/or modify it
+#  under the terms of the GNU General Public License as published by the Free
+#  Software Foundation, either version 3 of the License, or (at your option) any
+#  later version.
+#
+#  Wutta Framework is distributed in the hope that it will be useful, but
+#  WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+#  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
+#  more details.
+#
+#  You should have received a copy of the GNU General Public License along with
+#  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+Grid Filters
+"""
+
+import logging
+
+import sqlalchemy as sa
+
+from wuttjamaican.util import UNSPECIFIED
+
+
+log = logging.getLogger(__name__)
+
+
+class VerbNotSupported(Exception):
+    """ """
+
+    def __init__(self, verb):
+        self.verb = verb
+
+    def __str__(self):
+        return f"unknown filter verb not supported: {self.verb}"
+
+
+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`.
+    """
+    default_verbs = ['equal', 'not_equal']
+
+    default_verb_labels = {
+        'is_any':               "is any",
+        'equal':                "equal to",
+        'not_equal':            "not equal to",
+        'is_null':              "is null",
+        'is_not_null':          "is not null",
+        'is_true':              "is true",
+        'is_false':             "is false",
+        'contains':             "contains",
+        'does_not_contain':     "does not contain",
+    }
+
+    valueless_verbs = [
+        'is_any',
+        'is_null',
+        'is_not_null',
+        'is_true',
+        'is_false',
+    ]
+
+    def __init__(
+            self,
+            request,
+            key,
+            label=None,
+            verbs=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.label = label or self.app.make_title(self.key)
+
+        # active
+        self.default_active = default_active
+        self.active = self.default_active
+
+        # verb
+        if verbs is not None:
+            self.verbs = verbs
+        if default_verb:
+            self.default_verb = default_verb
+
+        # value
+        self.default_value = default_value
+        self.value = self.default_value
+
+        self.__dict__.update(kwargs)
+
+    def __repr__(self):
+        verb = getattr(self, 'verb', None)
+        return (f"{self.__class__.__name__}("
+                f"key='{self.key}', "
+                f"active={self.active}, "
+                f"verb={repr(verb)}, "
+                f"value={repr(self.value)})")
+
+    def get_verbs(self):
+        """
+        Returns the list of verbs supported by the filter.
+        """
+        verbs = None
+
+        if hasattr(self, 'verbs'):
+            verbs = self.verbs
+
+        else:
+            verbs = self.default_verbs
+
+        if callable(verbs):
+            verbs = verbs()
+        verbs = list(verbs)
+
+        if self.nullable:
+            if 'is_null' not in verbs:
+                verbs.append('is_null')
+            if 'is_not_null' not in verbs:
+                verbs.append('is_not_null')
+
+        if 'is_any' not in verbs:
+            verbs.append('is_any')
+
+        return verbs
+
+    def get_verb_labels(self):
+        """
+        Returns a dict of all defined verb labels.
+        """
+        # TODO: should traverse hierarchy
+        labels = dict([(verb, verb) for verb in self.get_verbs()])
+        labels.update(self.default_verb_labels)
+        return labels
+
+    def get_valueless_verbs(self):
+        """
+        Returns a list of verb names which do not need a value.
+        """
+        return self.valueless_verbs
+
+    def get_default_verb(self):
+        """
+        Returns the default verb for the filter.
+        """
+        verb = None
+
+        if hasattr(self, 'default_verb'):
+            verb = self.default_verb
+
+        elif hasattr(self, 'verb'):
+            verb = self.verb
+
+        if not verb:
+            verbs = self.get_verbs()
+            if verbs:
+                verb = verbs[0]
+
+        return verb
+
+    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:
+            verb = self.get_default_verb()
+            log.warn("missing verb for '%s' filter, will use default verb: %s",
+                     self.key, verb)
+
+        # only attempt for known verbs
+        if verb not in self.get_verbs():
+            raise VerbNotSupported(verb)
+
+        # fallback value
+        if value is UNSPECIFIED:
+            value = self.value
+
+        # locate filter method
+        func = getattr(self, f'filter_{verb}', None)
+        if not func:
+            raise VerbNotSupported(verb)
+
+        # invoke filter method
+        return func(data, value)
+
+    def filter_is_any(self, data, value):
+        """
+        This is a no-op which always ignores the value and returns the
+        data as-is.
+        """
+        return data
+
+
+class AlchemyFilter(GridFilter):
+    """
+    Filter option for a grid with SQLAlchemy query data.
+
+    This is a subclass of :class:`GridFilter`.  It requires a
+    ``model_property`` to know how to filter the query.
+
+    :param model_property: Property of a model class, representing the
+       column by which to filter.  For instance,
+       ``model.Person.full_name``.
+
+    :param nullable: Boolean indicating whether the filter should
+       include ``is_null`` and ``is_not_null`` verbs.  If not
+       specified, the column will be inspected and use its nullable
+       flag.
+    """
+
+    def __init__(self, *args, **kwargs):
+        nullable = kwargs.pop('nullable', None)
+        super().__init__(*args, **kwargs)
+
+        self.nullable = nullable
+        if self.nullable is None:
+            columns = self.model_property.prop.columns
+            if len(columns) == 1:
+                self.nullable = columns[0].nullable
+
+    def coerce_value(self, value):
+        """
+        Coerce the given value to the correct type/format for use with
+        the filter.
+
+        Default logic returns value as-is; subclass may override.
+        """
+        return value
+
+    def filter_equal(self, query, value):
+        """
+        Filter data with an equal (``=``) condition.
+        """
+        value = self.coerce_value(value)
+        if value is None:
+            return query
+
+        return query.filter(self.model_property == value)
+
+    def filter_not_equal(self, query, value):
+        """
+        Filter data with a not equal (``!=``) condition.
+        """
+        value = self.coerce_value(value)
+        if value is None:
+            return query
+
+        # sql probably excludes null values from results, but user
+        # probably does not expect that, so explicitly include them.
+        return query.filter(sa.or_(
+            self.model_property == None,
+            self.model_property != value,
+        ))
+
+    def filter_is_null(self, query, value):
+        """
+        Filter data with an ``IS NULL`` query.  The value is ignored.
+        """
+        return query.filter(self.model_property == None)
+
+    def filter_is_not_null(self, query, value):
+        """
+        Filter data with an ``IS NOT NULL`` query.  The value is
+        ignored.
+        """
+        return query.filter(self.model_property != None)
+
+
+class StringAlchemyFilter(AlchemyFilter):
+    """
+    SQLAlchemy filter option for a text data column.
+
+    Subclass of :class:`AlchemyFilter`.
+    """
+    default_verbs = ['contains', 'does_not_contain',
+                     'equal', 'not_equal']
+
+    def coerce_value(self, value):
+        """ """
+        if value is not None:
+            value = str(value)
+            if value:
+                return value
+
+    def filter_contains(self, query, value):
+        """
+        Filter data with an ``ILIKE`` condition.
+        """
+        value = self.coerce_value(value)
+        if not 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))
+
+    def filter_does_not_contain(self, query, value):
+        """
+        Filter data with a ``NOT ILIKE`` condition.
+        """
+        value = self.coerce_value(value)
+        if not value:
+            return query
+
+        criteria = []
+        for val in value.split():
+            val = val.replace('_', r'\_')
+            val = f'%{val}%'
+            criteria.append(~self.model_property.ilike(val))
+
+        # sql probably excludes null values from results, but user
+        # probably does not expect that, so explicitly include them.
+        return query.filter(sa.or_(
+            self.model_property == None,
+            sa.and_(*criteria)))
+
+
+class BooleanAlchemyFilter(AlchemyFilter):
+    """
+    SQLAlchemy filter option for a boolean data column.
+
+    Subclass of :class:`AlchemyFilter`.
+    """
+    default_verbs = ['is_true', 'is_false']
+
+    def coerce_value(self, value):
+        """ """
+        if value is not None:
+            return bool(value)
+
+    def filter_is_true(self, query, value):
+        """
+        Filter data with an "is true" condition.  The value is
+        ignored.
+        """
+        return query.filter(self.model_property == True)
+
+    def filter_is_false(self, query, value):
+        """
+        Filter data with an "is false" condition.  The value is
+        ignored.
+        """
+        return query.filter(self.model_property == False)
+
+
+default_sqlalchemy_filters = {
+    None: AlchemyFilter,
+    sa.String: StringAlchemyFilter,
+    sa.Text: StringAlchemyFilter,
+    sa.Boolean: BooleanAlchemyFilter,
+}
diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako
index 566f419..3145c1f 100644
--- a/src/wuttaweb/templates/wutta-components.mako
+++ b/src/wuttaweb/templates/wutta-components.mako
@@ -90,16 +90,18 @@
 
         <b-select v-model="filter.verb"
                   class="filter-verb"
+                  @input="focusValue()"
                   :size="isSmall ? 'is-small' : null">
           <option v-for="verb in filter.verbs"
                   :key="verb"
                   :value="verb">
-            {{ verb }}
+            {{ filter.verb_labels[verb] || verb }}
           </option>
         </b-select>
 
         <wutta-filter-value v-model="filter.value"
                             ref="filterValue"
+                            v-show="valuedVerb()"
                             :is-small="isSmall" />
 
       </div>
@@ -116,9 +118,26 @@
 
         methods: {
 
-            focusValue: function() {
+            focusValue() {
                 this.$refs.filterValue.focus()
-            }
+            },
+
+            valuedVerb() {
+                /* return true if the current verb should expose value input(s) */
+
+                // if filter has no "valueless" verbs, then all verbs should expose value inputs
+                if (!this.filter.valueless_verbs) {
+                    return true
+                }
+
+                // if filter *does* have valueless verbs, check if "current" verb is valueless
+                if (this.filter.valueless_verbs.includes(this.filter.verb)) {
+                    return false
+                }
+
+                // current verb is *not* valueless
+                return true
+            },
         }
     }
 
diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py
index 5726367..58d19c5 100644
--- a/tests/grids/test_base.py
+++ b/tests/grids/test_base.py
@@ -3,6 +3,7 @@
 from unittest import TestCase
 from unittest.mock import patch, MagicMock
 
+import sqlalchemy as sa
 from sqlalchemy import orm
 from paginate import Page
 from paginate_sqlalchemy import SqlalchemyOrmPage
@@ -10,6 +11,7 @@ from pyramid import testing
 
 from wuttjamaican.conf import WuttaConfig
 from wuttaweb.grids import base as mod
+from wuttaweb.grids.filters import GridFilter, StringAlchemyFilter, default_sqlalchemy_filters
 from wuttaweb.util import FieldList
 from wuttaweb.forms import Form
 from tests.util import WebTestCase
@@ -921,20 +923,38 @@ class TestGrid(WebTestCase):
     def test_make_filter(self):
         model = self.app.model
 
-        # basic
+        # arg is column name
         grid = self.make_grid(model_class=model.Setting)
         filtr = grid.make_filter('name')
-        self.assertIsInstance(filtr, mod.GridFilter)
+        self.assertIsInstance(filtr, StringAlchemyFilter)
 
-        # property
-        grid = self.make_grid(model_class=model.Setting)
-        filtr = grid.make_filter(model.Setting.name)
-        self.assertIsInstance(filtr, mod.GridFilter)
-
-        # invalid model class
+        # arg is column name, but model class is invalid
         grid = self.make_grid(model_class=42)
         self.assertRaises(ValueError, grid.make_filter, 'name')
 
+        # arg is model property
+        grid = self.make_grid(model_class=model.Setting)
+        filtr = grid.make_filter(model.Setting.name)
+        self.assertIsInstance(filtr, StringAlchemyFilter)
+
+        # model property as kwarg
+        grid = self.make_grid(model_class=model.Setting)
+        filtr = grid.make_filter(None, model_property=model.Setting.name)
+        self.assertIsInstance(filtr, StringAlchemyFilter)
+
+        # default factory
+        grid = self.make_grid(model_class=model.Setting)
+        with patch.dict(default_sqlalchemy_filters, {None: GridFilter}, clear=True):
+            filtr = grid.make_filter(model.Setting.name)
+        self.assertIsInstance(filtr, GridFilter)
+        self.assertNotIsInstance(filtr, StringAlchemyFilter)
+
+        # factory override
+        grid = self.make_grid(model_class=model.Setting)
+        filtr = grid.make_filter(model.Setting.name, factory=GridFilter)
+        self.assertIsInstance(filtr, GridFilter)
+        self.assertNotIsInstance(filtr, StringAlchemyFilter)
+
     def test_set_filter(self):
         model = self.app.model
 
@@ -1049,6 +1069,9 @@ class TestGrid(WebTestCase):
         sample_query = self.session.query(model.Setting)
 
         grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
+        self.assertEqual(list(grid.filters), ['name', 'value'])
+        self.assertIsInstance(grid.filters['name'], StringAlchemyFilter)
+        self.assertIsInstance(grid.filters['value'], StringAlchemyFilter)
 
         # not filtered by default
         grid.load_settings()
@@ -1421,86 +1444,3 @@ 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")
diff --git a/tests/grids/test_filters.py b/tests/grids/test_filters.py
new file mode 100644
index 0000000..2d8afdc
--- /dev/null
+++ b/tests/grids/test_filters.py
@@ -0,0 +1,385 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+from unittest.mock import patch
+
+from wuttaweb.grids import filters as mod
+from tests.util import WebTestCase
+
+
+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):
+        factory = kwargs.pop('factory', mod.GridFilter)
+        kwargs['model_property'] = model_property
+        return factory(self.request, model_property.key, **kwargs)
+
+    def test_constructor(self):
+        model = self.app.model
+
+        # verbs is not set by default, but can be set
+        filtr = self.make_filter(model.Setting.name)
+        self.assertFalse(hasattr(filtr, 'verbs'))
+        filtr = self.make_filter(model.Setting.name, verbs=['foo', 'bar'])
+        self.assertEqual(filtr.verbs, ['foo', 'bar'])
+
+        # verb is not set by default, but can be set
+        filtr = self.make_filter(model.Setting.name)
+        self.assertFalse(hasattr(filtr, 'verb'))
+        filtr = self.make_filter(model.Setting.name, verb='foo')
+        self.assertEqual(filtr.verb, 'foo')
+
+        # default verb is not set by default, but can be set
+        filtr = self.make_filter(model.Setting.name)
+        self.assertFalse(hasattr(filtr, 'default_verb'))
+        filtr = self.make_filter(model.Setting.name, default_verb='foo')
+        self.assertEqual(filtr.default_verb, 'foo')
+
+    def test_repr(self):
+        model = self.app.model
+        filtr = self.make_filter(model.Setting.name, factory=mod.GridFilter)
+        self.assertEqual(repr(filtr), "GridFilter(key='name', active=False, verb=None, value=None)")
+
+    def test_get_verbs(self):
+        model = self.app.model
+        filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
+        self.assertFalse(hasattr(filtr, 'verbs'))
+        self.assertEqual(filtr.default_verbs, ['equal', 'not_equal'])
+
+        # by default, returns default verbs (plus 'is_any')
+        self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
+
+        # default verbs can be a callable
+        filtr.default_verbs = lambda: ['foo', 'bar']
+        self.assertEqual(filtr.get_verbs(), ['foo', 'bar', 'is_any'])
+
+        # uses filtr.verbs if set
+        filtr.verbs = ['is_true', 'is_false']
+        self.assertEqual(filtr.get_verbs(), ['is_true', 'is_false', 'is_any'])
+
+        # may add is/null verbs
+        filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter,
+                                 nullable=True)
+        self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal',
+                                             'is_null', 'is_not_null',
+                                             'is_any'])
+
+        # filtr.verbs can be a callable
+        filtr.nullable = False
+        filtr.verbs = lambda: ['baz', 'blarg']
+        self.assertEqual(filtr.get_verbs(), ['baz', 'blarg', 'is_any'])
+
+    def test_get_default_verb(self):
+        model = self.app.model
+        filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
+        self.assertFalse(hasattr(filtr, 'verbs'))
+        self.assertEqual(filtr.default_verbs, ['equal', 'not_equal'])
+        self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
+
+        # returns first verb by default
+        self.assertEqual(filtr.get_default_verb(), 'equal')
+
+        # returns filtr.verb if set
+        filtr.verb = 'foo'
+        self.assertEqual(filtr.get_default_verb(), 'foo')
+
+        # returns filtr.default_verb if set
+        # (nb. this overrides filtr.verb since the point of this
+        # method is to return the *default* verb)
+        filtr.default_verb = 'bar'
+        self.assertEqual(filtr.get_default_verb(), 'bar')
+
+    def test_get_verb_labels(self):
+        model = self.app.model
+        filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
+        self.assertFalse(hasattr(filtr, 'verbs'))
+        self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
+
+        labels = filtr.get_verb_labels()
+        self.assertIsInstance(labels, dict)
+        self.assertEqual(labels['equal'], "equal to")
+        self.assertEqual(labels['not_equal'], "not equal to")
+        self.assertEqual(labels['is_any'], "is any")
+
+    def test_get_valueless_verbs(self):
+        model = self.app.model
+        filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
+        self.assertFalse(hasattr(filtr, 'verbs'))
+        self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
+
+        verbs = filtr.get_valueless_verbs()
+        self.assertIsInstance(verbs, list)
+        self.assertIn('is_any', verbs)
+
+    def test_apply_filter(self):
+        model = self.app.model
+        filtr = self.make_filter(model.Setting.value, factory=mod.StringAlchemyFilter)
+
+        # default verb used as fallback
+        # self.assertEqual(filtr.default_verb, 'contains')
+        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')
+        filtr.verbs = ['doesnotexist']
+        self.assertRaises(mod.VerbNotSupported, filtr.apply_filter,
+                          self.sample_query, verb='doesnotexist')
+
+    def test_filter_is_any(self):
+        model = self.app.model
+        filtr = self.make_filter(model.Setting.value)
+        self.assertEqual(self.sample_query.count(), 9)
+
+        # nb. value None is ignored
+        filtered_query = filtr.filter_is_any(self.sample_query, None)
+        self.assertIs(filtered_query, self.sample_query)
+        self.assertEqual(filtered_query.count(), 9)
+
+
+class TestAlchemyFilter(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': None},
+        ]
+        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):
+        factory = kwargs.pop('factory', mod.AlchemyFilter)
+        kwargs['model_property'] = model_property
+        return factory(self.request, model_property.key, **kwargs)
+
+    def test_filter_equal(self):
+        model = self.app.model
+        filtr = self.make_filter(model.Setting.value)
+        self.assertEqual(self.sample_query.count(), 9)
+
+        # not filtered for null value
+        filtered_query = filtr.filter_equal(self.sample_query, None)
+        self.assertIs(filtered_query, self.sample_query)
+
+        # nb. by default, *is filtered* by empty string
+        filtered_query = filtr.filter_equal(self.sample_query, '')
+        self.assertIsNot(filtered_query, self.sample_query)
+        self.assertEqual(filtered_query.count(), 0)
+
+        # filtered by value
+        filtered_query = filtr.filter_equal(self.sample_query, 'ggg')
+        self.assertIsNot(filtered_query, self.sample_query)
+        self.assertEqual(filtered_query.count(), 3)
+
+    def test_filter_not_equal(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_not_equal(self.sample_query, None)
+        self.assertIs(filtered_query, self.sample_query)
+
+        # nb. by default, *is filtered* by empty string
+        filtered_query = filtr.filter_not_equal(self.sample_query, '')
+        self.assertIsNot(filtered_query, self.sample_query)
+        self.assertEqual(filtered_query.count(), 9)
+
+        # filtered by value
+        filtered_query = filtr.filter_not_equal(self.sample_query, 'ggg')
+        self.assertIsNot(filtered_query, self.sample_query)
+        self.assertEqual(filtered_query.count(), 6)
+
+    def test_filter_is_null(self):
+        model = self.app.model
+        filtr = self.make_filter(model.Setting.value)
+        self.assertEqual(self.sample_query.count(), 9)
+
+        # nb. value None is ignored
+        filtered_query = filtr.filter_is_null(self.sample_query, None)
+        self.assertIsNot(filtered_query, self.sample_query)
+        self.assertEqual(filtered_query.count(), 1)
+
+    def test_filter_is_not_null(self):
+        model = self.app.model
+        filtr = self.make_filter(model.Setting.value)
+        self.assertEqual(self.sample_query.count(), 9)
+
+        # nb. value None is ignored
+        filtered_query = filtr.filter_is_not_null(self.sample_query, None)
+        self.assertIsNot(filtered_query, self.sample_query)
+        self.assertEqual(filtered_query.count(), 8)
+
+
+class TestStringAlchemyFilter(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):
+        factory = kwargs.pop('factory', mod.StringAlchemyFilter)
+        kwargs['model_property'] = model_property
+        return factory(self.request, model_property.key, **kwargs)
+
+    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)
+
+    def test_filter_does_not_contain(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_does_not_contain(self.sample_query, None)
+        self.assertIs(filtered_query, self.sample_query)
+        filtered_query = filtr.filter_does_not_contain(self.sample_query, '')
+        self.assertIs(filtered_query, self.sample_query)
+
+        # filtered by value
+        filtered_query = filtr.filter_does_not_contain(self.sample_query, 'ggg')
+        self.assertIsNot(filtered_query, self.sample_query)
+        self.assertEqual(filtered_query.count(), 6)
+
+
+class TestBooleanAlchemyFilter(WebTestCase):
+
+    def setUp(self):
+        self.setup_web()
+
+        model = self.app.model
+        self.sample_data = [
+            {'username': 'alice', 'active': True},
+            {'username': 'bob', 'active': True},
+            {'username': 'charlie', 'active': False},
+        ]
+        for user in self.sample_data:
+            user = model.User(**user)
+            self.session.add(user)
+        self.session.commit()
+        self.sample_query = self.session.query(model.User)
+
+    def make_filter(self, model_property, **kwargs):
+        factory = kwargs.pop('factory', mod.BooleanAlchemyFilter)
+        kwargs['model_property'] = model_property
+        return factory(self.request, model_property.key, **kwargs)
+
+    def test_coerce_value(self):
+        model = self.app.model
+        filtr = self.make_filter(model.User.active)
+
+        self.assertIsNone(filtr.coerce_value(None))
+
+        self.assertTrue(filtr.coerce_value(True))
+        self.assertTrue(filtr.coerce_value(1))
+        self.assertTrue(filtr.coerce_value('1'))
+
+        self.assertFalse(filtr.coerce_value(False))
+        self.assertFalse(filtr.coerce_value(0))
+        self.assertFalse(filtr.coerce_value(''))
+
+    def test_filter_is_true(self):
+        model = self.app.model
+        filtr = self.make_filter(model.User.active)
+        self.assertEqual(self.sample_query.count(), 3)
+
+        # nb. value None is ignored
+        filtered_query = filtr.filter_is_true(self.sample_query, None)
+        self.assertIsNot(filtered_query, self.sample_query)
+        self.assertEqual(filtered_query.count(), 2)
+
+    def test_filter_is_false(self):
+        model = self.app.model
+        filtr = self.make_filter(model.User.active)
+        self.assertEqual(self.sample_query.count(), 3)
+
+        # nb. value None is ignored
+        filtered_query = filtr.filter_is_false(self.sample_query, None)
+        self.assertIsNot(filtered_query, self.sample_query)
+        self.assertEqual(filtered_query.count(), 1)
+
+
+class TestVerbNotSupported(TestCase):
+
+    def test_basic(self):
+        error = mod.VerbNotSupported('equal')
+        self.assertEqual(str(error), "unknown filter verb not supported: equal")

From e332975ce9deb1eac659a9e936a8e49c21113168 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 23 Aug 2024 14:14:41 -0500
Subject: [PATCH 6/7] feat: add per-row css class support for grids

---
 src/wuttaweb/forms/base.py                    |   8 +-
 src/wuttaweb/grids/base.py                    | 149 ++++++++++++------
 .../templates/forms/vue_template.mako         |   6 +-
 .../templates/grids/table_element.mako        |   2 +-
 .../templates/grids/vue_template.mako         |  11 +-
 src/wuttaweb/views/master.py                  |  14 +-
 src/wuttaweb/views/users.py                   |  19 ++-
 tests/forms/test_base.py                      |  28 ++--
 tests/grids/test_base.py                      |  66 +++++++-
 tests/views/test_master.py                    |  14 ++
 tests/views/test_users.py                     |  11 ++
 11 files changed, 253 insertions(+), 75 deletions(-)

diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py
index d5b893a..c3567b5 100644
--- a/src/wuttaweb/forms/base.py
+++ b/src/wuttaweb/forms/base.py
@@ -313,7 +313,7 @@ class Form:
         self.set_fields(fields or self.get_fields())
 
         # nb. this tracks grid JSON data for inclusion in page template
-        self.grid_vue_data = OrderedDict()
+        self.grid_vue_context = OrderedDict()
 
     def __contains__(self, name):
         """
@@ -826,16 +826,16 @@ class Form:
         output = render(template, context)
         return HTML.literal(output)
 
-    def add_grid_vue_data(self, grid):
+    def add_grid_vue_context(self, grid):
         """ """
         if not grid.key:
             raise ValueError("grid must have a key!")
 
-        if grid.key in self.grid_vue_data:
+        if grid.key in self.grid_vue_context:
             log.warning("grid data with key '%s' already registered, "
                         "but will be replaced", grid.key)
 
-        self.grid_vue_data[grid.key] = grid.get_vue_data()
+        self.grid_vue_context[grid.key] = grid.get_vue_context()
 
     def render_vue_field(
             self,
diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py
index 0ec15f3..aa2e413 100644
--- a/src/wuttaweb/grids/base.py
+++ b/src/wuttaweb/grids/base.py
@@ -27,6 +27,7 @@ Base grid classes
 import functools
 import json
 import logging
+import warnings
 from collections import namedtuple
 
 import sqlalchemy as sa
@@ -116,6 +117,26 @@ class Grid:
 
        See also :meth:`set_renderer()`.
 
+    .. attribute:: row_class
+
+       This represents the CSS ``class`` attribute for a row within
+       the grid.  Default is ``None``.
+
+       This can be a simple string, in which case the same class is
+       applied to all rows.
+
+       Or it can be a callable, which can then return different
+       class(es) depending on each row.  The callable must take three
+       args: ``(obj, data, i)`` - for example::
+
+          def my_row_class(obj, data, i):
+              if obj.archived:
+                  return 'poser-archived'
+
+          grid = Grid(request, key='foo', row_class=my_row_class)
+
+       See :meth:`get_row_class()` for more info.
+
     .. attribute:: actions
 
        List of :class:`GridAction` instances represenging action links
@@ -330,6 +351,7 @@ class Grid:
             data=None,
             labels={},
             renderers={},
+            row_class=None,
             actions=[],
             linked_columns=[],
             sortable=False,
@@ -355,6 +377,7 @@ class Grid:
         self.data = data
         self.labels = labels or {}
         self.renderers = renderers or {}
+        self.row_class = row_class
         self.actions = actions or []
         self.linked_columns = linked_columns or []
         self.joiners = joiners or {}
@@ -530,8 +553,9 @@ class Grid:
         Depending on the nature of grid data, sometimes a cell's
         "as-is" value will be undesirable for display purposes.
 
-        The logic in :meth:`get_vue_data()` will first "convert" all
-        grid data as necessary so that it is at least JSON-compatible.
+        The logic in :meth:`get_vue_context()` will first "convert"
+        all grid data as necessary so that it is at least
+        JSON-compatible.
 
         But then it also will invoke a renderer override (if defined)
         to obtain the "final" cell value.
@@ -1670,7 +1694,7 @@ class Grid:
 
         .. code-block:: html
 
-           <b-table :data="gridData['mykey']">
+           <b-table :data="gridContext['mykey'].data">
              <!-- columns etc. -->
            </b-table>
 
@@ -1689,10 +1713,10 @@ class Grid:
 
         .. 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.
+           The above example shows ``gridContext['mykey'].data`` 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
@@ -1701,7 +1725,7 @@ class Grid:
 
         # nb. must register data for inclusion on page template
         if form:
-            form.add_grid_vue_data(self)
+            form.add_grid_vue_context(self)
 
         # otherwise logic is the same, just different template
         return self.render_vue_template(template=template, **context)
@@ -1809,7 +1833,7 @@ class Grid:
         in its `Table docs
         <https://buefy.org/documentation/table/#api-view>`_.
 
-        See also :meth:`get_vue_data()`.
+        See also :meth:`get_vue_context()`.
         """
         if not self.columns:
             raise ValueError(f"you must define columns for the grid! key = {self.key}")
@@ -1869,54 +1893,46 @@ class Grid:
             })
         return filters
 
-    def get_vue_data(self):
+    def get_vue_context(self):
         """
-        Returns a list of Vue-compatible data records.
+        Returns a dict of context for the grid, for use with the Vue
+        component.  This contains the following keys:
 
-        This calls :meth:`get_visible_data()` but then may modify the
-        result, e.g. to add URLs for :attr:`actions` etc.
+        * ``data`` - list of Vue-compatible data records
+        * ``row_classes`` - dict of per-row CSS classes
 
-        Importantly, this also ensures each value in the dict is
-        JSON-serializable, using
-        :func:`~wuttaweb.util.make_json_safe()`.
+        This first calls :meth:`get_visible_data()` to get the
+        original data set.  Each record is converted to a dict.
 
-        :returns: List of data record dicts for use with Vue table
-           component.  May be the full set of data, or just the
-           current page, per :attr:`paginate_on_backend`.
+        Then it calls :func:`~wuttaweb.util.make_json_safe()` to
+        ensure each record can be serialized to JSON.
+
+        Then it invokes any :attr:`renderers` which are defined, to
+        obtain the "final" values for each record.
+
+        Then it adds a URL key/value for each of the :attr:`actions`
+        defined, to each record.
+
+        Then it calls :meth:`get_row_class()` for each record.  If a
+        value is returned, it is added to the ``row_classes`` dict.
+        Note that this dict is keyed by "zero-based row sequence as
+        string" - the Vue component expects that.
+
+        :returns: Dict of grid data/CSS context as described above.
         """
         original_data = self.get_visible_data()
 
-        # TODO: at some point i thought it was useful to wrangle the
-        # columns here, but now i can't seem to figure out why..?
-
-        # # determine which columns are relevant for data set
-        # columns = None
-        # if not columns:
-        #     columns = self.get_columns()
-        #     if not columns:
-        #         raise ValueError("cannot determine columns for the grid")
-        # columns = set(columns)
-        # if self.model_class:
-        #     mapper = sa.inspect(self.model_class)
-        #     for column in mapper.primary_key:
-        #         columns.add(column.key)
-
-        # # prune data fields for which no column is defined
-        # for i, record in enumerate(original_data):
-        #     original_data[i]= dict([(key, record[key])
-        #                             for key in columns])
-
-        # we have action(s), so add URL(s) for each record in data
+        # loop thru data
         data = []
-        for i, record in enumerate(original_data):
+        row_classes = {}
+        for i, record in enumerate(original_data, 1):
             original_record = record
 
+            # convert record to new dict
             record = dict(record)
 
-            # convert data if needed, for json compat
-            record = make_json_safe(record,
-                                    # TODO: is this a good idea?
-                                    warn=False)
+            # make all values safe for json
+            record = make_json_safe(record, warn=False)
 
             # customize value rendering where applicable
             for key in self.renderers:
@@ -1931,9 +1947,48 @@ class Grid:
                     if url:
                         record[key] = url
 
+            # set row css class if applicable
+            css_class = self.get_row_class(original_record, record, i)
+            if css_class:
+                # nb. use *string* zero-based index, for js compat
+                row_classes[str(i-1)] = css_class
+
             data.append(record)
 
-        return data
+        return {
+            'data': data,
+            'row_classes': row_classes,
+        }
+
+    def get_vue_data(self):
+        """ """
+        warnings.warn("grid.get_vue_data() is deprecated; "
+                      "please use grid.get_vue_context() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.get_vue_context()['data']
+
+    def get_row_class(self, obj, data, i):
+        """
+        Returns the row CSS ``class`` attribute for the given record.
+        This method is called by :meth:`get_vue_context()`.
+
+        This will inspect/invoke :attr:`row_class` and return the
+        value obtained from there.
+
+        :param obj: Reference to the original model instance.
+
+        :param data: Dict of record data for the instance; part of the
+           Vue grid data set in/from :meth:`get_vue_context()`.
+
+        :param i: One-based sequence for this object/record (row)
+           within the grid.
+
+        :returns: String of CSS class name(s), or ``None``.
+        """
+        if self.row_class:
+            if callable(self.row_class):
+                return self.row_class(obj, data, i)
+            return self.row_class
 
     def get_vue_pager_stats(self):
         """
@@ -2086,7 +2141,7 @@ class GridAction:
         :param obj: Model instance of whatever type the parent grid is
            setup to use.
 
-        :param i: Zero-based sequence for the object, within the
+        :param i: One-based sequence for the object's row within the
            parent grid.
 
         See also :attr:`url`.
diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako
index e7d3f2b..5a4af70 100644
--- a/src/wuttaweb/templates/forms/vue_template.mako
+++ b/src/wuttaweb/templates/forms/vue_template.mako
@@ -69,9 +69,9 @@
 
       % endif
 
-      % if form.grid_vue_data:
-          gridData: {
-              % for key, data in form.grid_vue_data.items():
+      % if form.grid_vue_context:
+          gridContext: {
+              % for key, data in form.grid_vue_context.items():
                   '${key}': ${json.dumps(data)|n},
               % endfor
           },
diff --git a/src/wuttaweb/templates/grids/table_element.mako b/src/wuttaweb/templates/grids/table_element.mako
index ba35bf3..1bbf8a9 100644
--- a/src/wuttaweb/templates/grids/table_element.mako
+++ b/src/wuttaweb/templates/grids/table_element.mako
@@ -1,5 +1,5 @@
 ## -*- coding: utf-8; -*-
-<${b}-table :data="gridData['${grid.key}']">
+<${b}-table :data="gridContext['${grid.key}'].data">
 
   % for column in grid.get_vue_columns():
       <${b}-table-column field="${column['field']}"
diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako
index e701a4a..edcdc24 100644
--- a/src/wuttaweb/templates/grids/vue_template.mako
+++ b/src/wuttaweb/templates/grids/vue_template.mako
@@ -93,6 +93,7 @@
     </div>
 
     <${b}-table :data="data"
+                :row-class="getRowClass"
                 :loading="loading"
                 narrowed
                 hoverable
@@ -227,10 +228,12 @@
 
 <script>
 
-  let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n}
+  const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n}
+  let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data
 
   const ${grid.vue_component}Data = {
       data: ${grid.vue_component}CurrentData,
+      rowClasses: ${grid.vue_component}Context.row_classes,
       loading: false,
 
       ## nb. this tracks whether grid.fetchFirstData() happened
@@ -399,6 +402,11 @@
               })
           },
 
+          getRowClass(row, i) {
+              // nb. use *string* index
+              return this.rowClasses[i.toString()]
+          },
+
           renderNumber(value) {
               if (value != undefined) {
                   return value.toLocaleString('en')
@@ -457,6 +465,7 @@
                   if (!response.data.error) {
                       ${grid.vue_component}CurrentData = response.data.data
                       this.data = ${grid.vue_component}CurrentData
+                      this.rowClasses = response.data.row_classes || {}
                       % if grid.paginated and grid.paginate_on_backend:
                           this.pagerStats = response.data.pager_stats
                       % endif
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index 4258cfd..9187b23 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -181,6 +181,16 @@ class MasterView(View):
 
        This is optional; see also :meth:`get_grid_columns()`.
 
+    .. method:: grid_row_class(obj, data, i)
+
+       This method is *not* defined on the ``MasterView`` base class;
+       however if a subclass defines it then it will be automatically
+       used to provide :attr:`~wuttaweb.grids.base.Grid.row_class` for
+       the main :meth:`index()` grid.
+
+       For more info see
+       :meth:`~wuttaweb.grids.base.Grid.get_row_class()`.
+
     .. attribute:: filterable
 
        Boolean indicating whether the grid for the :meth:`index()`
@@ -360,7 +370,7 @@ class MasterView(View):
             if self.request.GET.get('partial'):
 
                 # so-called 'partial' requests get just data, no html
-                context = {'data': grid.get_vue_data()}
+                context = grid.get_vue_context()
                 if grid.paginated and grid.paginate_on_backend:
                     context['pager_stats'] = grid.get_vue_pager_stats()
                 return self.json_response(context)
@@ -1240,6 +1250,8 @@ class MasterView(View):
 
             kwargs['actions'] = actions
 
+        if hasattr(self, 'grid_row_class'):
+            kwargs.setdefault('row_class', self.grid_row_class)
         kwargs.setdefault('filterable', self.filterable)
         kwargs.setdefault('filter_defaults', self.filter_defaults)
         kwargs.setdefault('sortable', self.sortable)
diff --git a/src/wuttaweb/views/users.py b/src/wuttaweb/views/users.py
index 9c25dc3..91ed2e0 100644
--- a/src/wuttaweb/views/users.py
+++ b/src/wuttaweb/views/users.py
@@ -57,19 +57,24 @@ class UserView(MasterView):
 
     filter_defaults = {
         'username': {'active': True},
+        'active': {'active': True, 'verb': 'is_true'},
     }
     sort_defaults = 'username'
 
-    # 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.User.username)
+
+        # nb. always join Person
+        model = self.app.model
+        query = query.outerjoin(model.Person)
+
+        return query
 
     def configure_grid(self, g):
         """ """
         super().configure_grid(g)
+        model = self.app.model
 
         # never show these
         g.remove('person_uuid',
@@ -81,6 +86,14 @@ class UserView(MasterView):
 
         # person
         g.set_link('person')
+        g.set_sorter('person', model.Person.full_name)
+        g.set_filter('person', model.Person.full_name,
+                     label="Person Full Name")
+
+    def grid_row_class(self, user, data, i):
+        """ """
+        if not user.active:
+            return 'has-background-warning'
 
     def configure_form(self, f):
         """ """
diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py
index 50d27bc..70cb51f 100644
--- a/tests/forms/test_base.py
+++ b/tests/forms/test_base.py
@@ -406,28 +406,34 @@ class TestForm(TestCase):
         self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
         self.assertNotIn('@submit', html)
 
-    def test_add_grid_vue_data(self):
+    def test_add_grid_vue_context(self):
         form = self.make_form()
 
         # grid must have key
         grid = Grid(self.request)
-        self.assertRaises(ValueError, form.add_grid_vue_data, grid)
+        self.assertRaises(ValueError, form.add_grid_vue_context, grid)
 
         # otherwise it works
         grid = Grid(self.request, key='foo')
-        self.assertEqual(len(form.grid_vue_data), 0)
-        form.add_grid_vue_data(grid)
-        self.assertEqual(len(form.grid_vue_data), 1)
-        self.assertIn('foo', form.grid_vue_data)
-        self.assertEqual(form.grid_vue_data['foo'], [])
+        self.assertEqual(len(form.grid_vue_context), 0)
+        form.add_grid_vue_context(grid)
+        self.assertEqual(len(form.grid_vue_context), 1)
+        self.assertIn('foo', form.grid_vue_context)
+        self.assertEqual(form.grid_vue_context['foo'], {
+            'data': [],
+            'row_classes': {},
+        })
 
         # calling again with same key will replace data
         records = [{'foo': 1}, {'foo': 2}]
         grid = Grid(self.request, key='foo', columns=['foo'], data=records)
-        form.add_grid_vue_data(grid)
-        self.assertEqual(len(form.grid_vue_data), 1)
-        self.assertIn('foo', form.grid_vue_data)
-        self.assertEqual(form.grid_vue_data['foo'], records)
+        form.add_grid_vue_context(grid)
+        self.assertEqual(len(form.grid_vue_context), 1)
+        self.assertIn('foo', form.grid_vue_context)
+        self.assertEqual(form.grid_vue_context['foo'], {
+            'data': records,
+            'row_classes': {},
+        })
 
     def test_render_vue_finalize(self):
         form = self.make_form()
diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py
index 58d19c5..840715e 100644
--- a/tests/grids/test_base.py
+++ b/tests/grids/test_base.py
@@ -1286,10 +1286,10 @@ class TestGrid(WebTestCase):
 
         # form will register grid data
         form = Form(self.request)
-        self.assertEqual(len(form.grid_vue_data), 0)
+        self.assertEqual(len(form.grid_vue_context), 0)
         html = grid.render_table_element(form)
-        self.assertEqual(len(form.grid_vue_data), 1)
-        self.assertIn('foobar', form.grid_vue_data)
+        self.assertEqual(len(form.grid_vue_context), 1)
+        self.assertIn('foobar', form.grid_vue_context)
 
     def test_render_vue_finalize(self):
         grid = self.make_grid()
@@ -1337,6 +1337,40 @@ class TestGrid(WebTestCase):
         filters = grid.get_vue_filters()
         self.assertEqual(len(filters), 2)
 
+    def test_get_vue_context(self):
+
+        # empty if no columns defined
+        grid = self.make_grid()
+        context = grid.get_vue_context()
+        self.assertEqual(context, {'data': [], 'row_classes': {}})
+
+        # typical data is a list
+        mydata = [
+            {'foo': 'bar'},
+        ]
+        grid = self.make_grid(columns=['foo'], data=mydata)
+        context = grid.get_vue_context()
+        self.assertEqual(context, {'data': [{'foo': 'bar'}], 'row_classes': {}})
+
+        # if grid has actions, that list may be supplemented
+        grid.actions.append(mod.GridAction(self.request, 'view', url='/blarg'))
+        context = grid.get_vue_context()
+        self.assertIsNot(context['data'], mydata)
+        self.assertEqual(context, {'data': [{'foo': 'bar', '_action_url_view': '/blarg'}],
+                                   'row_classes': {}})
+
+        # can override value rendering
+        grid.set_renderer('foo', lambda record, key, value: "blah blah")
+        context = grid.get_vue_context()
+        self.assertEqual(context, {'data': [{'foo': 'blah blah', '_action_url_view': '/blarg'}],
+                                   'row_classes': {}})
+
+        # can set row class
+        grid.row_class = 'whatever'
+        context = grid.get_vue_context()
+        self.assertEqual(context, {'data': [{'foo': 'blah blah', '_action_url_view': '/blarg'}],
+                                   'row_classes': {'0': 'whatever'}})
+
     def test_get_vue_data(self):
 
         # empty if no columns defined
@@ -1358,11 +1392,35 @@ class TestGrid(WebTestCase):
         self.assertIsNot(data, mydata)
         self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])
 
-        # also can override value rendering
+        # can override value rendering
         grid.set_renderer('foo', lambda record, key, value: "blah blah")
         data = grid.get_vue_data()
         self.assertEqual(data, [{'foo': 'blah blah', '_action_url_view': '/blarg'}])
 
+    def test_get_row_class(self):
+        model = self.app.model
+        user = model.User(username='barney', active=True)
+        self.session.add(user)
+        self.session.commit()
+        data = dict(user)
+
+        # null by default
+        grid = self.make_grid()
+        self.assertIsNone(grid.get_row_class(user, data, 1))
+
+        # can use static class
+        grid.row_class = 'foo'
+        self.assertEqual(grid.get_row_class(user, data, 1), 'foo')
+
+        # can use callable
+        def status(u, d, i):
+            if not u.active:
+                return 'inactive'
+        grid.row_class = status
+        self.assertIsNone(grid.get_row_class(user, data, 1))
+        user.active = False
+        self.assertEqual(grid.get_row_class(user, data, 1), 'inactive')
+
     def test_get_vue_pager_stats(self):
         data = [
             {'foo': 1, 'bar': 1},
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index 7273265..d000693 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -487,6 +487,20 @@ class TestMasterView(WebTestCase):
             grid = view.make_model_grid(session=self.session)
             self.assertIs(grid.model_class, model.Setting)
 
+        # no row class by default
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Setting):
+            grid = view.make_model_grid(session=self.session)
+            self.assertIsNone(grid.row_class)
+
+        # can specify row class
+        get_row_class = MagicMock()
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Setting,
+                            grid_row_class=get_row_class):
+            grid = view.make_model_grid(session=self.session)
+            self.assertIs(grid.row_class, get_row_class)
+
         # no actions by default
         with patch.multiple(mod.MasterView, create=True,
                             model_class=model.Setting):
diff --git a/tests/views/test_users.py b/tests/views/test_users.py
index 54870ba..5b09e83 100644
--- a/tests/views/test_users.py
+++ b/tests/views/test_users.py
@@ -31,6 +31,17 @@ class TestUserView(WebTestCase):
         view.configure_grid(grid)
         self.assertTrue(grid.is_linked('person'))
 
+    def test_grid_row_class(self):
+        model = self.app.model
+        user = model.User(username='barney', active=True)
+        data = dict(user)
+        view = self.make_view()
+
+        self.assertIsNone(view.grid_row_class(user, data, 1))
+
+        user.active = False
+        self.assertEqual(view.grid_row_class(user, data, 1), 'has-background-warning')
+
     def test_configure_form(self):
         model = self.app.model
         barney = model.User(username='barney')

From bf2ca4b475ecddc4227dec25297b3d4105c2aaaf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 23 Aug 2024 14:38:02 -0500
Subject: [PATCH 7/7] fix: use autocomplete for grid filter verb choices

---
 src/wuttaweb/templates/base.mako             |  5 +-
 src/wuttaweb/templates/wutta-components.mako | 95 ++++++++++++++++++--
 2 files changed, 88 insertions(+), 12 deletions(-)

diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako
index c8557ca..a85bc2d 100644
--- a/src/wuttaweb/templates/base.mako
+++ b/src/wuttaweb/templates/base.mako
@@ -164,12 +164,9 @@
         gap: 0.5rem;
     }
 
-    .wutta-filter .button.filter-toggle {
-        justify-content: left;
-    }
-
     .wutta-filter .button.filter-toggle,
     .wutta-filter .filter-verb {
+        justify-content: left;
         min-width: 15rem;
     }
 
diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako
index 3145c1f..888944e 100644
--- a/src/wuttaweb/templates/wutta-components.mako
+++ b/src/wuttaweb/templates/wutta-components.mako
@@ -88,16 +88,26 @@
       <div v-show="filter.active"
            style="display: flex; gap: 0.5rem;">
 
-        <b-select v-model="filter.verb"
+        <b-button v-if="verbKnown"
                   class="filter-verb"
-                  @input="focusValue()"
+                  @click="verbChoiceInit()"
                   :size="isSmall ? 'is-small' : null">
-          <option v-for="verb in filter.verbs"
-                  :key="verb"
-                  :value="verb">
-            {{ filter.verb_labels[verb] || verb }}
-          </option>
-        </b-select>
+          {{ verbLabel }}
+        </b-button>
+
+        <b-autocomplete v-if="!verbKnown"
+                        ref="verbAutocomplete"
+                        :data="verbOptions"
+                        v-model="verbTerm"
+                        field="verb"
+                        :custom-formatter="formatVerb"
+                        open-on-focus
+                        keep-first
+                        clearable
+                        clear-on-select
+                        @select="verbChoiceSelect"
+                        icon-pack="fas"
+                        :size="isSmall ? 'is-small' : null" />
 
         <wutta-filter-value v-model="filter.value"
                             ref="filterValue"
@@ -116,12 +126,81 @@
             isSmall: Boolean,
         },
 
+        data() {
+            return {
+                verbKnown: !!this.filter.verb,
+                verbLabel: this.filter.verb_labels[this.filter.verb],
+                verbTerm: '',
+            }
+        },
+
+        computed: {
+
+            verbOptions() {
+
+                // construct list of options
+                const options = []
+                for (let verb of this.filter.verbs) {
+                    options.push({
+                        verb,
+                        label: this.filter.verb_labels[verb],
+                    })
+                }
+
+                // parse list of search terms
+                const terms = []
+                for (let term of this.verbTerm.toLowerCase().split(' ')) {
+                    term = term.trim()
+                    if (term) {
+                        terms.push(term)
+                    }
+                }
+
+                // show all if no search terms
+                if (!terms.length) {
+                    return options
+                }
+
+                // only show filters matching all search terms
+                return options.filter(option => {
+                    let label = option.label.toLowerCase()
+                    for (let term of terms) {
+                        if (label.indexOf(term) < 0) {
+                            return false
+                        }
+                    }
+                    return true
+                })
+
+                return options
+            },
+        },
+
         methods: {
 
             focusValue() {
                 this.$refs.filterValue.focus()
             },
 
+            formatVerb(option) {
+                return option.label || option.verb
+            },
+
+            verbChoiceInit(option) {
+                this.verbKnown = false
+                this.$nextTick(() => {
+                    this.$refs.verbAutocomplete.focus()
+                })
+            },
+
+            verbChoiceSelect(option) {
+                this.filter.verb = option.verb
+                this.verbLabel = option.label
+                this.verbKnown = true
+                this.verbTerm = ''
+                this.focusValue()
+            },
+
             valuedVerb() {
                 /* return true if the current verb should expose value input(s) */