3
0
Fork 0

fix: add value choice/enum support for grid filters

also add `set_enum()` method for grids, which updates column renderer
as well as filter choices
This commit is contained in:
Lance Edgar 2025-02-21 13:27:52 -06:00
parent 8357fd594f
commit 37ae69de00
5 changed files with 291 additions and 1 deletions

View file

@ -119,6 +119,12 @@ class Grid:
See also :meth:`set_renderer()` and See also :meth:`set_renderer()` and
:meth:`set_default_renderers()`. :meth:`set_default_renderers()`.
.. attribute:: enums
Dict of "enum" collections, for supported columns.
See also :meth:`set_enum()`.
.. attribute:: checkable .. attribute:: checkable
Boolean indicating whether the grid should expose per-row Boolean indicating whether the grid should expose per-row
@ -377,6 +383,7 @@ class Grid:
data=None, data=None,
labels={}, labels={},
renderers={}, renderers={},
enums={},
checkable=False, checkable=False,
row_class=None, row_class=None,
actions=[], actions=[],
@ -458,6 +465,11 @@ class Grid:
self.filters = {} self.filters = {}
self.set_filter_defaults(**(filter_defaults or {})) self.set_filter_defaults(**(filter_defaults or {}))
# enums
self.enums = {}
for key in enums:
self.set_enum(key, enums[key])
def get_columns(self): def get_columns(self):
""" """
Returns the official list of column names for the grid, or Returns the official list of column names for the grid, or
@ -720,6 +732,27 @@ class Grid:
elif isinstance(column.type, sa.Boolean): elif isinstance(column.type, sa.Boolean):
self.set_renderer(key, self.render_boolean) self.set_renderer(key, self.render_boolean)
def set_enum(self, key, enum):
"""
Set the "enum" collection for a given column.
This will set the column renderer to show the appropriate enum
value for each row in the grid. See also
:meth:`render_enum()`.
If the grid has a corresponding filter for the column, it will
be modified to show "choices" for values contained in the
enum.
:param key: Name of column.
:param enum: Instance of :class:`python:enum.Enum`.
"""
self.enums[key] = enum
self.set_renderer(key, self.render_enum, enum=enum)
if key in self.filters:
self.filters[key].set_choices(enum)
def set_link(self, key, link=True): def set_link(self, key, link=True):
""" """
Explicitly enable or disable auto-link behavior for a given Explicitly enable or disable auto-link behavior for a given
@ -1945,6 +1978,33 @@ class Grid:
dt = getattr(obj, key) dt = getattr(obj, key)
return self.app.render_datetime(dt) return self.app.render_datetime(dt)
def render_enum(self, obj, key, value, enum=None):
"""
Custom grid value renderer for "enum" fields.
See also :meth:`set_enum()`.
:param enum: Enum class for the field. This should be an
instance of :class:`~python:enum.Enum`.
To use this feature for your grid::
from enum import Enum
class MyEnum(Enum):
ONE = 1
TWO = 2
THREE = 3
grid.set_enum('my_enum_field', MyEnum)
"""
if enum:
raw_value = obj[key]
if raw_value:
return raw_value.value
return value
def render_percent(self, obj, key, value, **kwargs): def render_percent(self, obj, key, value, **kwargs):
""" """
Column renderer for percentage values. Column renderer for percentage values.
@ -2176,6 +2236,13 @@ class Grid:
""" """
filters = [] filters = []
for filtr in self.filters.values(): for filtr in self.filters.values():
choices = []
choice_labels = {}
if filtr.choices:
choices = list(filtr.choices)
choice_labels = dict(filtr.choices)
filters.append({ filters.append({
'key': filtr.key, 'key': filtr.key,
'data_type': filtr.data_type, 'data_type': filtr.data_type,
@ -2185,6 +2252,8 @@ class Grid:
'verb_labels': filtr.get_verb_labels(), 'verb_labels': filtr.get_verb_labels(),
'valueless_verbs': filtr.get_valueless_verbs(), 'valueless_verbs': filtr.get_valueless_verbs(),
'verb': filtr.verb, 'verb': filtr.verb,
'choices': choices,
'choice_labels': choice_labels,
'value': filtr.value, 'value': filtr.value,
'label': filtr.label, 'label': filtr.label,
}) })

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# wuttaweb -- Web App for Wutta Framework # wuttaweb -- Web App for Wutta Framework
# Copyright © 2024 Lance Edgar # Copyright © 2024-2025 Lance Edgar
# #
# This file is part of Wutta Framework. # This file is part of Wutta Framework.
# #
@ -26,6 +26,8 @@ Grid Filters
import datetime import datetime
import logging import logging
from collections import OrderedDict
from enum import EnumType
import sqlalchemy as sa import sqlalchemy as sa
@ -77,6 +79,7 @@ class GridFilter:
* ``'string'`` * ``'string'``
* ``'date'`` * ``'date'``
* ``'choice'``
Note that this mainly applies to the "value input" used by the Note that this mainly applies to the "value input" used by the
filter. There is no data type for boolean since it does not filter. There is no data type for boolean since it does not
@ -94,6 +97,13 @@ class GridFilter:
See also :attr:`value`. See also :attr:`value`.
.. attribute:: choices
OrderedDict of possible values for the filter.
This is safe to read from, but use :meth:`set_choices()` to
update it.
.. attribute:: value .. attribute:: value
Value for current filter, if :attr:`active` is true. Value for current filter, if :attr:`active` is true.
@ -159,6 +169,7 @@ class GridFilter:
key, key,
label=None, label=None,
verbs=None, verbs=None,
choices={},
default_active=False, default_active=False,
default_verb=None, default_verb=None,
default_value=None, default_value=None,
@ -180,6 +191,9 @@ class GridFilter:
if default_verb: if default_verb:
self.default_verb = default_verb self.default_verb = default_verb
# choices
self.set_choices(choices)
# value # value
self.default_value = default_value self.default_value = default_value
self.value = self.default_value self.value = self.default_value
@ -255,6 +269,72 @@ class GridFilter:
return verb return verb
def set_choices(self, choices):
"""
Set the value choices for the filter.
If ``choices`` is non-empty, it is passed to
:meth:`normalize_choices()` and the result is assigned to
:attr:`choices`. Also, the :attr:`data_type` is set to
``'choice'`` so the UI will present the value input as a
dropdown.
But if ``choices`` is empty, :attr:`choices` is set to an
empty dict, and :attr:`data_type` is set (back) to
``'string'``.
:param choices: Collection of "choices" or ``None``.
"""
if choices:
self.choices = self.normalize_choices(choices)
self.data_type = 'choice'
else:
self.choices = {}
self.data_type = 'string'
def normalize_choices(self, choices):
"""
Normalize a collection of "choices" to standard ``OrderedDict``.
This is called automatically by :meth:`set_choices()`.
:param choices: A collection of "choices" in one of the following
formats:
* :class:`python:enum.Enum` class
* simple list, each value of which should be a string,
which is assumed to be able to serve as both key and
value (ordering of choices will be preserved)
* simple dict, keys and values of which will define the
choices (note that the final choices will be sorted by
key!)
* OrderedDict, keys and values of which will define the
choices (ordering of choices will be preserved)
:rtype: :class:`python:collections.OrderedDict`
"""
normalized = choices
if isinstance(choices, EnumType):
normalized = OrderedDict([
(member.name, member.value)
for member in choices])
elif isinstance(choices, OrderedDict):
normalized = choices
elif isinstance(choices, dict):
normalized = OrderedDict([
(key, choices[key])
for key in sorted(choices)])
elif isinstance(choices, list):
normalized = OrderedDict([
(key, key)
for key in choices])
return normalized
def apply_filter(self, data, verb=None, value=UNSPECIFIED): def apply_filter(self, data, verb=None, value=UNSPECIFIED):
""" """
Filter the given data set according to a verb/value pair. Filter the given data set according to a verb/value pair.

View file

@ -486,6 +486,17 @@
v-show="valuedVerb()" v-show="valuedVerb()"
:is-small="isSmall" /> :is-small="isSmall" />
<b-select v-if="filter.data_type == 'choice'"
v-model="filter.value"
ref="filterValue"
v-show="valuedVerb()">
<option v-for="choice in filter.choices"
:key="choice"
:value="choice">
{{ filter.choice_labels[choice] || choice }}
</option>
</b-select>
<wutta-filter-value v-else <wutta-filter-value v-else
v-model="filter.value" v-model="filter.value"
ref="filterValue" ref="filterValue"

View file

@ -2,6 +2,8 @@
import datetime import datetime
import decimal import decimal
from collections import OrderedDict
from enum import Enum
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -261,6 +263,36 @@ class TestGrid(WebTestCase):
self.assertIn('created', grid.renderers) self.assertIn('created', grid.renderers)
self.assertIsNot(grid.renderers['created'], myrender) self.assertIsNot(grid.renderers['created'], myrender)
def test_set_enum(self):
model = self.app.model
class MockEnum(Enum):
FOO = 'foo'
BAR = 'bar'
# no enums by default
grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.enums, {})
# enum is set, but not filter choices
grid = self.make_grid(columns=['foo', 'bar'],
filterable=False,
enums={'foo': MockEnum})
self.assertIs(grid.enums['foo'], MockEnum)
self.assertEqual(grid.filters, {})
# both enum and filter choices are set
grid = self.make_grid(model_class=model.Setting,
filterable=True,
enums={'name': MockEnum})
self.assertIs(grid.enums['name'], MockEnum)
self.assertIn('name', grid.filters)
self.assertIn('value', grid.filters)
self.assertEqual(grid.filters['name'].choices, OrderedDict([
('FOO', 'foo'),
('BAR', 'bar'),
]))
def test_linked_columns(self): def test_linked_columns(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.linked_columns, []) self.assertEqual(grid.linked_columns, [])
@ -1432,6 +1464,20 @@ class TestGrid(WebTestCase):
value = decimal.Decimal('-42.42') value = decimal.Decimal('-42.42')
self.assertEqual(grid.render_currency(obj, 'foo', value), '($42.42)') self.assertEqual(grid.render_currency(obj, 'foo', value), '($42.42)')
def test_render_enum(self):
enum = self.app.enum
grid = self.make_grid(columns=['foo', 'bar'])
obj = {'status': None}
# null
value = grid.render_enum(obj, 'status', None, enum=enum.UpgradeStatus)
self.assertIsNone(value)
# normal
obj['status'] = enum.UpgradeStatus.SUCCESS
value = grid.render_enum(obj, 'status', 'SUCCESS', enum=enum.UpgradeStatus)
self.assertEqual(value, 'success')
def test_render_percent(self): def test_render_percent(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
obj = MagicMock() obj = MagicMock()
@ -1572,6 +1618,27 @@ class TestGrid(WebTestCase):
grid.load_settings() grid.load_settings()
filters = grid.get_vue_filters() filters = grid.get_vue_filters()
self.assertEqual(len(filters), 2) self.assertEqual(len(filters), 2)
name, value = filters
self.assertEqual(name['choices'], [])
self.assertEqual(name['choice_labels'], {})
self.assertEqual(value['choices'], [])
self.assertEqual(value['choice_labels'], {})
class MockEnum(Enum):
FOO = 'foo'
BAR = 'bar'
# with filter choices
grid = self.make_grid(key='settings', model_class=model.Setting,
filterable=True, enums={'name': MockEnum})
grid.load_settings()
filters = grid.get_vue_filters()
self.assertEqual(len(filters), 2)
name, value = filters
self.assertEqual(name['choices'], ['FOO', 'BAR'])
self.assertEqual(name['choice_labels'], {'FOO': 'foo', 'BAR': 'bar'})
self.assertEqual(value['choices'], [])
self.assertEqual(value['choice_labels'], {})
def test_object_to_dict(self): def test_object_to_dict(self):
grid = self.make_grid() grid = self.make_grid()

View file

@ -1,6 +1,8 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
import datetime import datetime
from collections import OrderedDict
from enum import Enum
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch from unittest.mock import patch
@ -136,6 +138,67 @@ class TestGridFilter(WebTestCase):
self.assertIsInstance(verbs, list) self.assertIsInstance(verbs, list)
self.assertIn('is_any', verbs) self.assertIn('is_any', verbs)
def test_set_choices(self):
model = self.app.model
filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
self.assertEqual(filtr.choices, {})
self.assertEqual(filtr.data_type, 'string')
class MockEnum(Enum):
FOO = 'foo'
BAR = 'bar'
filtr.set_choices(MockEnum)
self.assertEqual(filtr.choices, OrderedDict([
('FOO', 'foo'),
('BAR', 'bar'),
]))
self.assertEqual(filtr.data_type, 'choice')
filtr.set_choices(None)
self.assertEqual(filtr.choices, {})
self.assertEqual(filtr.data_type, 'string')
def test_normalize_choices(self):
model = self.app.model
filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
class MockEnum(Enum):
FOO = 'foo'
BAR = 'bar'
choices = filtr.normalize_choices(MockEnum)
self.assertEqual(choices, OrderedDict([
('FOO', 'foo'),
('BAR', 'bar'),
]))
choices = filtr.normalize_choices(OrderedDict([
('first', '1'),
('second', '2'),
]))
self.assertEqual(choices, OrderedDict([
('first', '1'),
('second', '2'),
]))
choices = filtr.normalize_choices({
'bbb': 'b',
'aaa': 'a',
})
self.assertEqual(choices, OrderedDict([
('aaa', 'a'),
('bbb', 'b'),
]))
choices = filtr.normalize_choices(['one', 'two', 'three'])
self.assertEqual(choices, OrderedDict([
('one', 'one'),
('two', 'two'),
('three', 'three'),
]))
def test_apply_filter(self): def test_apply_filter(self):
model = self.app.model model = self.app.model
filtr = self.make_filter(model.Setting.value, factory=mod.StringAlchemyFilter) filtr = self.make_filter(model.Setting.value, factory=mod.StringAlchemyFilter)