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:
parent
8357fd594f
commit
37ae69de00
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in a new issue