From 122c6650d542c9f109b56134655d639ac3f2cefa Mon Sep 17 00:00:00 2001 From: Lance Edgar 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 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: From db5e4e88f632d3e6728806863a7f2820ab22208d Mon Sep 17 00:00:00 2001 From: Lance Edgar 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 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" /> - Reset View + {{ viewResetting ? "Working, please wait..." : "Reset View" }} 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 @@
% if grid.filterable: -
+
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 . +# +################################################################################ +""" +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 @@
@@ -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 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 - + @@ -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 `_. - 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 @@
<${b}-table :data="data" + :row-class="getRowClass" :loading="loading" narrowed hoverable @@ -227,10 +228,12 @@