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/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 3e7695c..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 @@ -40,6 +41,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__) @@ -115,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 @@ -329,6 +351,7 @@ class Grid: data=None, labels={}, renderers={}, + row_class=None, actions=[], linked_columns=[], sortable=False, @@ -354,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 {} @@ -529,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. @@ -1034,8 +1059,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 +1069,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 +1264,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 +1327,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 +1567,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] @@ -1658,7 +1694,7 @@ class Grid: .. code-block:: html - + @@ -1677,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 @@ -1689,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) @@ -1797,7 +1833,7 @@ class Grid: in its `Table docs `_. - 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}") @@ -1848,61 +1884,55 @@ 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, }) 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: @@ -1917,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): """ @@ -2072,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`. @@ -2081,164 +2150,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 . +# +################################################################################ +""" +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/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/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 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 name="render_vue_templates()"> + ${parent.render_vue_templates()} + ${self.render_vue_template_form()} + + <%def name="make_vue_components()"> ${parent.make_vue_components()} % if form is not Undefined: 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 84dcb58..edcdc24 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -5,8 +5,7 @@
% if grid.filterable: -
+
- Reset View + {{ viewResetting ? "Working, please wait..." : "Reset View" }} <${b}-table :data="data" + :row-class="getRowClass" :loading="loading" narrowed hoverable @@ -227,10 +228,12 @@