diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0935339..b17ddc7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,30 @@ All notable changes to wuttaweb will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
+## v0.12.0 (2024-08-22)
+
+### Feat
+
+- add "copy link" button for sharing a grid view
+- add initial support for proper grid filters
+- add initial filtering logic to grid class
+- add "searchable" column support for grids
+- improve page linkage between role/user/person
+- add basic autocomplete support, for Person
+
+### Fix
+
+- cleanup templates for home, login pages
+- cleanup logic for appinfo/configure
+- expose settings for app node title, type
+- show installed python packages on appinfo page
+- tweak login form to stop extending size of background card
+- add setting to auto-redirect anon users to login, from home page
+- add form padding, validators for /configure pages
+- add padding around main form, via wrapper css
+- show CRUD buttons in header only if relevant and user has access
+- tweak style config for home link app title in main menu
+
## v0.11.0 (2024-08-20)
### Feat
diff --git a/pyproject.toml b/pyproject.toml
index fe91ac9..9fc1d83 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "WuttaWeb"
-version = "0.11.0"
+version = "0.12.0"
description = "Web App for Wutta Framework"
readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -41,7 +41,7 @@ dependencies = [
"pyramid_tm",
"waitress",
"WebHelpers2",
- "WuttJamaican[db]>=0.12.0",
+ "WuttJamaican[db]>=0.12.1",
"zope.sqlalchemy>=1.5",
]
diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py
index 5acca59..d5b893a 100644
--- a/src/wuttaweb/forms/base.py
+++ b/src/wuttaweb/forms/base.py
@@ -25,6 +25,7 @@ Base form classes
"""
import logging
+from collections import OrderedDict
import colander
import deform
@@ -311,6 +312,9 @@ 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()
+
def __contains__(self, name):
"""
Custom logic for the ``in`` operator, to allow easily checking
@@ -750,6 +754,10 @@ class Form:
kwargs['appstruct'] = self.model_instance
form = deform.Form(schema, **kwargs)
+ # nb. must give a reference back to wutta form; this is
+ # for sake of field schema nodes and widgets, e.g. to
+ # access the main model instance
+ form.wutta_form = self
self.deform_form = form
return self.deform_form
@@ -818,6 +826,17 @@ class Form:
output = render(template, context)
return HTML.literal(output)
+ def add_grid_vue_data(self, grid):
+ """ """
+ if not grid.key:
+ raise ValueError("grid must have a key!")
+
+ if grid.key in self.grid_vue_data:
+ 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()
+
def render_vue_field(
self,
fieldname,
diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py
index 8c245f6..a3a464b 100644
--- a/src/wuttaweb/forms/schema.py
+++ b/src/wuttaweb/forms/schema.py
@@ -246,6 +246,9 @@ class ObjectRef(colander.SchemaType):
values.insert(0, self.empty_option)
kwargs['values'] = values
+ if 'url' not in kwargs:
+ kwargs['url'] = lambda person: self.request.route_url('people.view', uuid=person.uuid)
+
return widgets.ObjectRefWidget(self.request, **kwargs)
@@ -321,6 +324,28 @@ class RoleRefs(WuttaSet):
return widgets.RoleRefsWidget(self.request, **kwargs)
+class UserRefs(WuttaSet):
+ """
+ Form schema type for the Role
+ :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users`
+ association proxy field.
+
+ This is a subclass of :class:`WuttaSet`. It uses a ``set`` of
+ :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` ``uuid``
+ values for underlying data format.
+ """
+
+ def widget_maker(self, **kwargs):
+ """
+ Constructs a default widget for the field.
+
+ :returns: Instance of
+ :class:`~wuttaweb.forms.widgets.UserRefsWidget`.
+ """
+ kwargs.setdefault('session', self.session)
+ return widgets.UserRefsWidget(self.request, **kwargs)
+
+
class Permissions(WuttaSet):
"""
Form schema type for the Role
diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py
index b4d8254..ee58a1a 100644
--- a/src/wuttaweb/forms/widgets.py
+++ b/src/wuttaweb/forms/widgets.py
@@ -44,6 +44,7 @@ from deform.widget import (Widget, TextInputWidget, TextAreaWidget,
from webhelpers2.html import HTML
from wuttaweb.db import Session
+from wuttaweb.grids import Grid
class ObjectRefWidget(SelectWidget):
@@ -83,9 +84,19 @@ class ObjectRefWidget(SelectWidget):
"""
readonly_template = 'readonly/objectref'
- def __init__(self, request, *args, **kwargs):
+ def __init__(self, request, url=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
+ self.url = url
+
+ def get_template_values(self, field, cstruct, kw):
+ """ """
+ values = super().get_template_values(field, cstruct, kw)
+
+ if 'url' not in values and self.url and field.schema.model_instance:
+ values['url'] = self.url(field.schema.model_instance)
+
+ return values
class NotesWidget(TextAreaWidget):
@@ -137,12 +148,17 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for use with User
:attr:`~wuttjamaican:wuttjamaican.db.model.auth.User.roles` field.
+ This is the default widget for the
+ :class:`~wuttaweb.forms.schema.RoleRefs` type.
This is a subclass of :class:`WuttaCheckboxChoiceWidget`.
"""
+ readonly_template = 'readonly/rolerefs'
def serialize(self, field, cstruct, **kw):
""" """
+ model = self.app.model
+
# special logic when field is editable
readonly = kw.get('readonly', self.readonly)
if not readonly:
@@ -159,10 +175,78 @@ class RoleRefsWidget(WuttaCheckboxChoiceWidget):
if val[0] != admin.uuid]
kw['values'] = values
+ else: # readonly
+
+ # roles
+ roles = []
+ if cstruct:
+ for uuid in cstruct:
+ role = self.session.query(model.Role).get(uuid)
+ if role:
+ roles.append(role)
+ kw['roles'] = roles
+
+ # url
+ url = lambda role: self.request.route_url('roles.view', uuid=role.uuid)
+ kw['url'] = url
+
# default logic from here
return super().serialize(field, cstruct, **kw)
+class UserRefsWidget(WuttaCheckboxChoiceWidget):
+ """
+ Widget for use with Role
+ :attr:`~wuttjamaican:wuttjamaican.db.model.auth.Role.users` field.
+ This is the default widget for the
+ :class:`~wuttaweb.forms.schema.UserRefs` type.
+
+ This is a subclass of :class:`WuttaCheckboxChoiceWidget`; however
+ it only supports readonly mode and does not use a template.
+ Rather, it generates and renders a
+ :class:`~wuttaweb.grids.base.Grid` showing the users list.
+ """
+
+ def serialize(self, field, cstruct, **kw):
+ """ """
+ readonly = kw.get('readonly', self.readonly)
+ if not readonly:
+ raise NotImplementedError("edit not allowed for this widget")
+
+ model = self.app.model
+ columns = ['person', 'username', 'active']
+
+ # generate data set for users
+ users = []
+ if cstruct:
+ for uuid in cstruct:
+ user = self.session.query(model.User).get(uuid)
+ if user:
+ users.append(dict([(key, getattr(user, key))
+ for key in columns + ['uuid']]))
+
+ # grid
+ grid = Grid(self.request, key='roles.view.users',
+ columns=columns, data=users)
+
+ # view action
+ if self.request.has_perm('users.view'):
+ url = lambda user, i: self.request.route_url('users.view', uuid=user['uuid'])
+ grid.add_action('view', icon='eye', url=url)
+ grid.set_link('person')
+ grid.set_link('username')
+
+ # edit action
+ if self.request.has_perm('users.edit'):
+ url = lambda user, i: self.request.route_url('users.edit', uuid=user['uuid'])
+ grid.add_action('edit', url=url)
+
+ # render as simple
+ # nb. must indicate we are a part of this form
+ form = getattr(field.parent, 'wutta_form', None)
+ return grid.render_table_element(form)
+
+
class PermissionsWidget(WuttaCheckboxChoiceWidget):
"""
Widget for use with Role
diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py
index 3f22aad..3e7695c 100644
--- a/src/wuttaweb/grids/base.py
+++ b/src/wuttaweb/grids/base.py
@@ -39,6 +39,7 @@ from webhelpers2.html import HTML
from wuttaweb.db import Session
from wuttaweb.util import FieldList, get_model_fields, make_json_safe
+from wuttjamaican.util import UNSPECIFIED
log = logging.getLogger(__name__)
@@ -282,6 +283,40 @@ class Grid:
Only relevant if :attr:`paginated` is true. If not specified,
constructor will assume ``1`` (first page).
+
+ .. attribute:: searchable_columns
+
+ Set of columns declared as searchable for the Vue component.
+
+ See also :meth:`set_searchable()` and :meth:`is_searchable()`.
+
+ .. attribute:: filterable
+
+ Boolean indicating whether the grid should show a "filters"
+ section where user can filter data in various ways. Default is
+ ``False``.
+
+ .. attribute:: filters
+
+ Dict of :class:`~wuttaweb.grids.filters.GridFilter` instances
+ available for use with backend filtering.
+
+ Only relevant if :attr:`filterable` is true.
+
+ See also :meth:`set_filter()`.
+
+ .. attribute:: filter_defaults
+
+ Dict containing default state preferences for the filters.
+
+ See also :meth:`set_filter_defaults()`.
+
+ .. attribute:: joiners
+
+ Dict of "joiner" functions for use with backend filtering and
+ sorting.
+
+ See :meth:`set_joiner()` for more info.
"""
def __init__(
@@ -306,6 +341,11 @@ class Grid:
pagesize_options=None,
pagesize=None,
page=1,
+ searchable_columns=None,
+ filterable=False,
+ filters=None,
+ filter_defaults=None,
+ joiners=None,
):
self.request = request
self.vue_tagname = vue_tagname
@@ -316,6 +356,7 @@ class Grid:
self.renderers = renderers or {}
self.actions = actions or []
self.linked_columns = linked_columns or []
+ self.joiners = joiners or {}
self.config = self.request.wutta_config
self.app = self.config.get_app()
@@ -344,6 +385,19 @@ class Grid:
self.pagesize = pagesize or self.get_pagesize()
self.page = page
+ # searching
+ self.searchable_columns = set(searchable_columns or [])
+
+ # filtering
+ self.filterable = filterable
+ if filters is not None:
+ self.filters = filters
+ elif self.filterable:
+ self.filters = self.make_backend_filters()
+ else:
+ self.filters = {}
+ self.set_filter_defaults(**(filter_defaults or {}))
+
def get_columns(self):
"""
Returns the official list of column names for the grid, or
@@ -432,7 +486,7 @@ class Grid:
if key in self.columns:
self.columns.remove(key)
- def set_label(self, key, label):
+ def set_label(self, key, label, column_only=False):
"""
Set/override the label for a column.
@@ -440,11 +494,18 @@ class Grid:
:param label: New label for the column header.
+ :param column_only: Boolean indicating whether the label
+ should be applied *only* to the column header (if
+ ``True``), vs. applying also to the filter (if ``False``).
+
See also :meth:`get_label()`. Label overrides are tracked via
:attr:`labels`.
"""
self.labels[key] = label
+ if not column_only and key in self.filters:
+ self.filters[key].label = label
+
def get_label(self, key):
"""
Returns the label text for a given column.
@@ -543,6 +604,92 @@ class Grid:
return True
return False
+ def set_searchable(self, key, searchable=True):
+ """
+ (Un)set the given column's searchable flag for the Vue
+ component.
+
+ See also :meth:`is_searchable()`. Flags are tracked via
+ :attr:`searchable_columns`.
+ """
+ if searchable:
+ self.searchable_columns.add(key)
+ elif key in self.searchable_columns:
+ self.searchable_columns.remove(key)
+
+ def is_searchable(self, key):
+ """
+ Check if the given column is marked as searchable for the Vue
+ component.
+
+ See also :meth:`set_searchable()`.
+ """
+ return key in self.searchable_columns
+
+ def add_action(self, key, **kwargs):
+ """
+ Convenience to add a new :class:`GridAction` instance to the
+ grid's :attr:`actions` list.
+ """
+ self.actions.append(GridAction(self.request, key, **kwargs))
+
+ ##############################
+ # joining methods
+ ##############################
+
+ def set_joiner(self, key, joiner):
+ """
+ Set/override the backend joiner for a column.
+
+ A "joiner" is sometimes needed when a column with "related but
+ not primary" data is involved in a sort or filter operation.
+
+ A sorter or filter may need to "join" other table(s) to get at
+ the appropriate data. But if a given column has both a sorter
+ and filter defined, and both are used at the same time, we
+ don't want the join to happen twice.
+
+ Hence we track joiners separately, also keyed by column name
+ (as are sorters and filters). When a column's sorter **and/or**
+ filter is needed, the joiner will be invoked.
+
+ :param key: Name of column.
+
+ :param joiner: A joiner callable, as described below.
+
+ A joiner callable must accept just one ``(data)`` arg and
+ return the "joined" data/query, for example::
+
+ model = app.model
+ grid = Grid(request, model_class=model.Person)
+
+ def join_external_profile_value(query):
+ return query.join(model.ExternalProfile)
+
+ def sort_external_profile(query, direction):
+ sortspec = getattr(model.ExternalProfile.description, direction)
+ return query.order_by(sortspec())
+
+ grid.set_joiner('external_profile', join_external_profile)
+ grid.set_sorter('external_profile', sort_external_profile)
+
+ See also :meth:`remove_joiner()`. Backend joiners are tracked
+ via :attr:`joiners`.
+ """
+ self.joiners[key] = joiner
+
+ def remove_joiner(self, key):
+ """
+ Remove the backend joiner for a column.
+
+ Note that this removes the joiner *function*, so there is no
+ way to apply joins for this column unless another joiner is
+ later defined for it.
+
+ See also :meth:`set_joiner()`.
+ """
+ self.joiners.pop(key, None)
+
##############################
# sorting methods
##############################
@@ -844,6 +991,147 @@ class Grid:
return key in self.sorters
return True
+ ##############################
+ # filtering methods
+ ##############################
+
+ def make_backend_filters(self, filters=None):
+ """
+ Make backend filters for all columns in the grid.
+
+ This is called by the constructor, if :attr:`filterable` is
+ true.
+
+ For each column in the grid, this checks the provided
+ ``filters`` and if the column is not yet in there, will call
+ :meth:`make_filter()` to add it.
+
+ .. note::
+
+ This only works if grid has a :attr:`model_class`. If not,
+ this method just returns the initial filters (or empty
+ dict).
+
+ :param filters: Optional dict of initial filters. Any
+ existing filters will be left intact, not replaced.
+
+ :returns: Final dict of all filters. Includes any from the
+ initial ``filters`` param as well as any which were
+ created.
+ """
+ filters = filters or {}
+
+ if self.model_class:
+ for key in self.columns:
+ if key in filters:
+ continue
+ prop = getattr(self.model_class, key, None)
+ if (prop and hasattr(prop, 'property')
+ and isinstance(prop.property, orm.ColumnProperty)):
+ filters[prop.key] = self.make_filter(prop)
+
+ return filters
+
+ def make_filter(self, columninfo, **kwargs):
+ """
+ Create and return a :class:`GridFilter` instance suitable for
+ use on the given column.
+
+ Code usually does not need to call this directly. See also
+ :meth:`set_filter()`, which calls this method automatically.
+
+ :param columninfo: Can be either a model property (see below),
+ or a column name.
+
+ :returns: A :class:`GridFilter` instance.
+ """
+ model_property = None
+ if isinstance(columninfo, str):
+ key = columninfo
+ if self.model_class:
+ try:
+ mapper = sa.inspect(self.model_class)
+ except sa.exc.NoInspectionAvailable:
+ pass
+ else:
+ model_property = mapper.get_property(key)
+ if not model_property:
+ raise ValueError(f"cannot locate model property for key: {key}")
+ else:
+ model_property = columninfo
+
+ return GridFilter(self.request, model_property, **kwargs)
+
+ def set_filter(self, key, filterinfo=None, **kwargs):
+ """
+ Set/override the backend filter for a column.
+
+ Only relevant if :attr:`filterable` is true.
+
+ :param key: Name of column.
+
+ :param filterinfo: Can be either a
+ :class:`~wuttweb.grids.filters.GridFilter` instance, or
+ else a model property (see below).
+
+ If ``filterinfo`` is a ``GridFilter`` instance, it will be
+ used as-is for the backend filter.
+
+ Otherwise :meth:`make_filter()` will be called to obtain the
+ backend filter. The ``filterinfo`` will be passed along to
+ that call; if it is empty then ``key`` will be used instead.
+
+ See also :meth:`remove_filter()`. Backend filters are tracked
+ via :attr:`filters`.
+ """
+ filtr = None
+
+ if filterinfo and callable(filterinfo):
+ # filtr = filterinfo
+ raise NotImplementedError
+ else:
+ kwargs.setdefault('label', self.get_label(key))
+ filtr = self.make_filter(filterinfo or key, **kwargs)
+
+ self.filters[key] = filtr
+
+ def remove_filter(self, key):
+ """
+ Remove the backend filter for a column.
+
+ This removes the filter *instance*, so there is no way to
+ filter by this column unless another filter is later defined
+ for it.
+
+ See also :meth:`set_filter()`.
+ """
+ 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
##############################
@@ -934,6 +1222,15 @@ class Grid:
# initial default settings
settings = {}
+ if self.filterable:
+ for filtr in self.filters.values():
+ defaults = self.filter_defaults.get(filtr.key, {})
+ settings[f'filter.{filtr.key}.active'] = defaults.get('active',
+ filtr.default_active)
+ settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
+ filtr.default_verb)
+ settings[f'filter.{filtr.key}.value'] = defaults.get('value',
+ filtr.default_value)
if self.sortable:
if self.sort_defaults:
# nb. as of writing neither Buefy nor Oruga support a
@@ -951,11 +1248,27 @@ class Grid:
# update settings dict based on what we find in the request
# and/or user session. always prioritize the former.
- if self.request_has_settings('sort'):
+ # nb. do not read settings if user wants a reset
+ if self.request.GET.get('reset-view'):
+ # at this point we only have default settings, and we want
+ # to keep those *and* persist them for next time, below
+ pass
+
+ elif self.request_has_settings('filter'):
+ self.update_filter_settings(settings, src='request')
+ if self.request_has_settings('sort'):
+ self.update_sort_settings(settings, src='request')
+ else:
+ self.update_sort_settings(settings, src='session')
+ self.update_page_settings(settings)
+
+ elif self.request_has_settings('sort'):
+ self.update_filter_settings(settings, src='session')
self.update_sort_settings(settings, src='request')
self.update_page_settings(settings)
elif self.request_has_settings('page'):
+ self.update_filter_settings(settings, src='session')
self.update_sort_settings(settings, src='session')
self.update_page_settings(settings)
@@ -964,6 +1277,7 @@ class Grid:
persist = False
# but still should load whatever is in user session
+ self.update_filter_settings(settings, src='session')
self.update_sort_settings(settings, src='session')
self.update_page_settings(settings)
@@ -973,6 +1287,13 @@ class Grid:
# update ourself to reflect settings dict..
+ # filtering
+ if self.filterable:
+ for filtr in self.filters.values():
+ filtr.active = settings[f'filter.{filtr.key}.active']
+ filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.default_verb
+ filtr.value = settings[f'filter.{filtr.key}.value']
+
# sorting
if self.sortable:
# nb. doing this for frontend sorting also
@@ -997,11 +1318,18 @@ class Grid:
def request_has_settings(self, typ):
""" """
- if typ == 'sort':
+ if typ == 'filter' and self.filterable:
+ for filtr in self.filters.values():
+ if filtr.key in self.request.GET:
+ return True
+ if 'filter' in self.request.GET: # user may be applying empty filters
+ return True
+
+ elif typ == 'sort' and self.sortable and self.sort_on_backend:
if 'sort1key' in self.request.GET:
return True
- elif typ == 'page':
+ elif typ == 'page' and self.paginated and self.paginate_on_backend:
for key in ['pagesize', 'page']:
if key in self.request.GET:
return True
@@ -1033,6 +1361,31 @@ class Grid:
# okay then, default it is
return default
+ def update_filter_settings(self, settings, src=None):
+ """ """
+ if not self.filterable:
+ return
+
+ for filtr in self.filters.values():
+ prefix = f'filter.{filtr.key}'
+
+ if src == 'request':
+ # consider filter active if query string contains a value for it
+ settings[f'{prefix}.active'] = filtr.key in self.request.GET
+ settings[f'{prefix}.verb'] = self.get_setting(
+ settings, f'{filtr.key}.verb', src='request', default='')
+ settings[f'{prefix}.value'] = self.get_setting(
+ settings, filtr.key, src='request', default='')
+
+ elif src == 'session':
+ settings[f'{prefix}.active'] = self.get_setting(
+ settings, f'{prefix}.active', src='session',
+ normalize=lambda v: str(v).lower() == 'true', default=False)
+ settings[f'{prefix}.verb'] = self.get_setting(
+ settings, f'{prefix}.verb', src='session', default='')
+ settings[f'{prefix}.value'] = self.get_setting(
+ settings, f'{prefix}.value', src='session', default='')
+
def update_sort_settings(self, settings, src=None):
""" """
if not (self.sortable and self.sort_on_backend):
@@ -1099,8 +1452,18 @@ class Grid:
skey = f'grid.{self.key}.{key}'
self.request.session[skey] = value(key)
+ # filter settings
+ if self.filterable:
+
+ # always save all filters, with status
+ for filtr in self.filters.values():
+ persist(f'filter.{filtr.key}.active',
+ value=lambda k: 'true' if settings.get(k) else 'false')
+ persist(f'filter.{filtr.key}.verb')
+ persist(f'filter.{filtr.key}.value')
+
# sort settings
- if self.sortable:
+ if self.sortable and self.sort_on_backend:
# first must clear all sort settings from dest. this is
# because number of sort settings will vary, so we delete
@@ -1144,10 +1507,15 @@ class Grid:
See also these methods which may be called by this one:
+ * :meth:`filter_data()`
* :meth:`sort_data()`
* :meth:`paginate_data()`
"""
data = self.data or []
+ self.joined = set()
+
+ if self.filterable:
+ data = self.filter_data(data)
if self.sortable and self.sort_on_backend:
data = self.sort_data(data)
@@ -1158,6 +1526,46 @@ class Grid:
return data
+ @property
+ def active_filters(self):
+ """
+ Returns the list of currently active filters.
+
+ This inspects each :class:`GridFilter` in :attr:`filters` and
+ only returns the ones marked active.
+ """
+ return [filtr for filtr in self.filters.values()
+ if filtr.active]
+
+ def filter_data(self, data, filters=None):
+ """
+ Filter the given data and return the result. This is called
+ by :meth:`get_visible_data()`.
+
+ :param filters: Optional list of filters to use. If not
+ specified, the grid's :attr:`active_filters` are used.
+ """
+ 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):
"""
Sort the given data and return the result. This is called by
@@ -1188,6 +1596,11 @@ class Grid:
if not sortfunc:
return data
+ # join appropriate model if needed
+ if sortkey in self.joiners and sortkey not in self.joined:
+ data = self.joiners[sortkey](data)
+ self.joined.add(sortkey)
+
# invoke the sorter
data = sortfunc(data, sortdir)
@@ -1229,6 +1642,58 @@ class Grid:
# rendering methods
##############################
+ def render_table_element(
+ self,
+ form=None,
+ template='/grids/table_element.mako',
+ **context):
+ """
+ Render a simple Vue table element for the grid.
+
+ This is what you want for a "simple" grid which does require a
+ unique Vue component, but can instead use the standard table
+ component.
+
+ This returns something like:
+
+ .. code-block:: html
+
+
+
+
+
+ See :meth:`render_vue_template()` for a more complete variant.
+
+ Actual output will of course depend on grid attributes,
+ :attr:`key`, :attr:`columns` etc.
+
+ :param form: Reference to the
+ :class:`~wuttaweb.forms.base.Form` instance which
+ "contains" this grid. This is needed in order to ensure
+ the grid data is available to the form Vue component.
+
+ :param template: Path to Mako template which is used to render
+ the output.
+
+ .. note::
+
+ The above example shows ``gridData['mykey']`` as the Vue
+ data reference. This should "just work" if you provide the
+ correct ``form`` arg and the grid is contained directly by
+ that form's Vue component.
+
+ However, this may not account for all use cases. For now
+ we wait and see what comes up, but know the dust may not
+ yet be settled here.
+ """
+
+ # nb. must register data for inclusion on page template
+ if form:
+ form.add_grid_vue_data(self)
+
+ # otherwise logic is the same, just different template
+ return self.render_vue_template(template=template, **context)
+
def render_vue_tag(self, **kwargs):
"""
Render the Vue component tag for the grid.
@@ -1251,6 +1716,9 @@ class Grid:
"""
Render the Vue template block for the grid.
+ This is what you want for a "full-featured" grid which will
+ exist as its own unique Vue component on the frontend.
+
This returns something like:
.. code-block:: none
@@ -1261,12 +1729,21 @@ class Grid:
+
+
.. todo::
Why can't Sphinx render the above code block as 'html' ?
It acts like it can't handle a ``
%def>
diff --git a/src/wuttaweb/templates/auth/login.mako b/src/wuttaweb/templates/auth/login.mako
index e6b77c6..278992b 100644
--- a/src/wuttaweb/templates/auth/login.mako
+++ b/src/wuttaweb/templates/auth/login.mako
@@ -4,13 +4,9 @@
<%def name="title()">Login%def>
-<%def name="render_this_page()">
- ${self.page_content()}
-%def>
-
<%def name="page_content()">
-
${base_meta.full_logo()}
+
${base_meta.full_logo(image_url or None)}
${form.render_vue_tag()}
@@ -44,6 +40,3 @@
%def>
-
-
-${parent.body()}
diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako
index f58f7ec..6c57c46 100644
--- a/src/wuttaweb/templates/base.mako
+++ b/src/wuttaweb/templates/base.mako
@@ -151,6 +151,33 @@
white-space: nowrap;
}
+ ##############################
+ ## grids
+ ##############################
+
+ .wutta-filter {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .wutta-filter .button.filter-toggle {
+ justify-content: left;
+ }
+
+ .wutta-filter .button.filter-toggle,
+ .wutta-filter .filter-verb {
+ min-width: 15rem;
+ }
+
+ .wutta-filter .filter-verb .select,
+ .wutta-filter .filter-verb .select select {
+ width: 100%;
+ }
+
+ ##############################
+ ## forms
+ ##############################
+
.wutta-form-wrapper {
margin-left: 5rem;
margin-top: 2rem;
@@ -501,7 +528,7 @@
label="Delete This" />
% endif
% elif master.editing:
- % if instance_viewable and master.has_perm('view'):
+ % if master.has_perm('view'):
% endif
% elif master.deleting:
- % if instance_viewable and master.has_perm('view'):
+ % if master.has_perm('view'):
${app.get_title()}%def>
+<%def name="global_title()">${app.get_node_title()}%def>
<%def name="extra_styles()">%def>
@@ -12,8 +12,8 @@
${h.image(config.get('wuttaweb.header_logo_url', default=request.static_url('wuttaweb:static/img/favicon.ico')), "Header Logo", style="height: 49px;")}
%def>
-<%def name="full_logo()">
- ${h.image(config.get('wuttaweb.logo_url', default=request.static_url('wuttaweb:static/img/logo.png')), f"{app.get_title()} logo")}
+<%def name="full_logo(image_url=None)">
+ ${h.image(image_url or config.get('wuttaweb.logo_url', default=request.static_url('wuttaweb:static/img/logo.png')), f"{app.get_title()} logo")}
%def>
<%def name="footer()">
diff --git a/src/wuttaweb/templates/configure.mako b/src/wuttaweb/templates/configure.mako
index 6b7a766..f0363e0 100644
--- a/src/wuttaweb/templates/configure.mako
+++ b/src/wuttaweb/templates/configure.mako
@@ -3,6 +3,17 @@
<%def name="title()">Configure ${config_title}%def>
+<%def name="extra_styles()">
+ ${parent.extra_styles()}
+
+%def>
+
<%def name="page_content()">
${self.buttons_content()}
@@ -42,15 +53,14 @@
Cancel
- ${h.form(request.current_route_url())}
+ ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})}
${h.csrf_token(request)}
${h.hidden('remove_settings', 'true')}
+ icon-left="trash">
{{ purgingSettings ? "Working, please wait..." : "Remove All Settings for ${config_title}" }}
${h.end_form()}
diff --git a/src/wuttaweb/templates/deform/readonly/objectref.pt b/src/wuttaweb/templates/deform/readonly/objectref.pt
index 0b941ab..3ab9e0e 100644
--- a/src/wuttaweb/templates/deform/readonly/objectref.pt
+++ b/src/wuttaweb/templates/deform/readonly/objectref.pt
@@ -1 +1,9 @@
-${str(field.schema.model_instance or '')}
+
+
+ ${str(field.schema.model_instance or '')}
+
+
+ ${str(field.schema.model_instance or '')}
+
+
diff --git a/src/wuttaweb/templates/deform/readonly/rolerefs.pt b/src/wuttaweb/templates/deform/readonly/rolerefs.pt
new file mode 100644
index 0000000..ba27041
--- /dev/null
+++ b/src/wuttaweb/templates/deform/readonly/rolerefs.pt
@@ -0,0 +1,7 @@
+
diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako
index 70540d0..e7d3f2b 100644
--- a/src/wuttaweb/templates/forms/vue_template.mako
+++ b/src/wuttaweb/templates/forms/vue_template.mako
@@ -11,7 +11,12 @@
% if not form.readonly:
-