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
:meth:`set_default_renderers()`.
.. attribute:: enums
Dict of "enum" collections, for supported columns.
See also :meth:`set_enum()`.
.. attribute:: checkable
Boolean indicating whether the grid should expose per-row
@ -377,6 +383,7 @@ class Grid:
data=None,
labels={},
renderers={},
enums={},
checkable=False,
row_class=None,
actions=[],
@ -458,6 +465,11 @@ class Grid:
self.filters = {}
self.set_filter_defaults(**(filter_defaults or {}))
# enums
self.enums = {}
for key in enums:
self.set_enum(key, enums[key])
def get_columns(self):
"""
Returns the official list of column names for the grid, or
@ -720,6 +732,27 @@ class Grid:
elif isinstance(column.type, sa.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):
"""
Explicitly enable or disable auto-link behavior for a given
@ -1945,6 +1978,33 @@ class Grid:
dt = getattr(obj, key)
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):
"""
Column renderer for percentage values.
@ -2176,6 +2236,13 @@ class Grid:
"""
filters = []
for filtr in self.filters.values():
choices = []
choice_labels = {}
if filtr.choices:
choices = list(filtr.choices)
choice_labels = dict(filtr.choices)
filters.append({
'key': filtr.key,
'data_type': filtr.data_type,
@ -2185,6 +2252,8 @@ class Grid:
'verb_labels': filtr.get_verb_labels(),
'valueless_verbs': filtr.get_valueless_verbs(),
'verb': filtr.verb,
'choices': choices,
'choice_labels': choice_labels,
'value': filtr.value,
'label': filtr.label,
})

View file

@ -2,7 +2,7 @@
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024 Lance Edgar
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Wutta Framework.
#
@ -26,6 +26,8 @@ Grid Filters
import datetime
import logging
from collections import OrderedDict
from enum import EnumType
import sqlalchemy as sa
@ -77,6 +79,7 @@ class GridFilter:
* ``'string'``
* ``'date'``
* ``'choice'``
Note that this mainly applies to the "value input" used by the
filter. There is no data type for boolean since it does not
@ -94,6 +97,13 @@ class GridFilter:
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
Value for current filter, if :attr:`active` is true.
@ -159,6 +169,7 @@ class GridFilter:
key,
label=None,
verbs=None,
choices={},
default_active=False,
default_verb=None,
default_value=None,
@ -180,6 +191,9 @@ class GridFilter:
if default_verb:
self.default_verb = default_verb
# choices
self.set_choices(choices)
# value
self.default_value = default_value
self.value = self.default_value
@ -255,6 +269,72 @@ class GridFilter:
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):
"""
Filter the given data set according to a verb/value pair.

View file

@ -486,6 +486,17 @@
v-show="valuedVerb()"
: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
v-model="filter.value"
ref="filterValue"

View file

@ -2,6 +2,8 @@
import datetime
import decimal
from collections import OrderedDict
from enum import Enum
from unittest import TestCase
from unittest.mock import patch, MagicMock
@ -261,6 +263,36 @@ class TestGrid(WebTestCase):
self.assertIn('created', grid.renderers)
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):
grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.linked_columns, [])
@ -1432,6 +1464,20 @@ class TestGrid(WebTestCase):
value = decimal.Decimal('-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):
grid = self.make_grid(columns=['foo', 'bar'])
obj = MagicMock()
@ -1572,6 +1618,27 @@ class TestGrid(WebTestCase):
grid.load_settings()
filters = grid.get_vue_filters()
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):
grid = self.make_grid()

View file

@ -1,6 +1,8 @@
# -*- coding: utf-8; -*-
import datetime
from collections import OrderedDict
from enum import Enum
from unittest import TestCase
from unittest.mock import patch
@ -136,6 +138,67 @@ class TestGridFilter(WebTestCase):
self.assertIsInstance(verbs, list)
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):
model = self.app.model
filtr = self.make_filter(model.Setting.value, factory=mod.StringAlchemyFilter)