1
0
Fork 0

feat: add per-row css class support for grids

This commit is contained in:
Lance Edgar 2024-08-23 14:14:41 -05:00
parent f6fb6957e3
commit e332975ce9
11 changed files with 253 additions and 75 deletions

View file

@ -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,

View file

@ -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
<b-table :data="gridData['mykey']">
<b-table :data="gridContext['mykey'].data">
<!-- columns etc. -->
</b-table>
@ -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
<https://buefy.org/documentation/table/#api-view>`_.
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`.

View file

@ -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
},

View file

@ -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']}"

View file

@ -93,6 +93,7 @@
</div>
<${b}-table :data="data"
:row-class="getRowClass"
:loading="loading"
narrowed
hoverable
@ -227,10 +228,12 @@
<script>
let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n}
const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n}
let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data
const ${grid.vue_component}Data = {
data: ${grid.vue_component}CurrentData,
rowClasses: ${grid.vue_component}Context.row_classes,
loading: false,
## nb. this tracks whether grid.fetchFirstData() happened
@ -399,6 +402,11 @@
})
},
getRowClass(row, i) {
// nb. use *string* index
return this.rowClasses[i.toString()]
},
renderNumber(value) {
if (value != undefined) {
return value.toLocaleString('en')
@ -457,6 +465,7 @@
if (!response.data.error) {
${grid.vue_component}CurrentData = response.data.data
this.data = ${grid.vue_component}CurrentData
this.rowClasses = response.data.row_classes || {}
% if grid.paginated and grid.paginate_on_backend:
this.pagerStats = response.data.pager_stats
% endif

View file

@ -181,6 +181,16 @@ class MasterView(View):
This is optional; see also :meth:`get_grid_columns()`.
.. method:: grid_row_class(obj, data, i)
This method is *not* defined on the ``MasterView`` base class;
however if a subclass defines it then it will be automatically
used to provide :attr:`~wuttaweb.grids.base.Grid.row_class` for
the main :meth:`index()` grid.
For more info see
:meth:`~wuttaweb.grids.base.Grid.get_row_class()`.
.. attribute:: filterable
Boolean indicating whether the grid for the :meth:`index()`
@ -360,7 +370,7 @@ class MasterView(View):
if self.request.GET.get('partial'):
# so-called 'partial' requests get just data, no html
context = {'data': grid.get_vue_data()}
context = grid.get_vue_context()
if grid.paginated and grid.paginate_on_backend:
context['pager_stats'] = grid.get_vue_pager_stats()
return self.json_response(context)
@ -1240,6 +1250,8 @@ class MasterView(View):
kwargs['actions'] = actions
if hasattr(self, 'grid_row_class'):
kwargs.setdefault('row_class', self.grid_row_class)
kwargs.setdefault('filterable', self.filterable)
kwargs.setdefault('filter_defaults', self.filter_defaults)
kwargs.setdefault('sortable', self.sortable)

View file

@ -57,19 +57,24 @@ class UserView(MasterView):
filter_defaults = {
'username': {'active': True},
'active': {'active': True, 'verb': 'is_true'},
}
sort_defaults = 'username'
# TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None):
""" """
model = self.app.model
query = super().get_query(session=session)
return query.order_by(model.User.username)
# nb. always join Person
model = self.app.model
query = query.outerjoin(model.Person)
return query
def configure_grid(self, g):
""" """
super().configure_grid(g)
model = self.app.model
# never show these
g.remove('person_uuid',
@ -81,6 +86,14 @@ class UserView(MasterView):
# person
g.set_link('person')
g.set_sorter('person', model.Person.full_name)
g.set_filter('person', model.Person.full_name,
label="Person Full Name")
def grid_row_class(self, user, data, i):
""" """
if not user.active:
return 'has-background-warning'
def configure_form(self, f):
""" """

View file

@ -406,28 +406,34 @@ class TestForm(TestCase):
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
self.assertNotIn('@submit', html)
def test_add_grid_vue_data(self):
def test_add_grid_vue_context(self):
form = self.make_form()
# grid must have key
grid = Grid(self.request)
self.assertRaises(ValueError, form.add_grid_vue_data, grid)
self.assertRaises(ValueError, form.add_grid_vue_context, grid)
# otherwise it works
grid = Grid(self.request, key='foo')
self.assertEqual(len(form.grid_vue_data), 0)
form.add_grid_vue_data(grid)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foo', form.grid_vue_data)
self.assertEqual(form.grid_vue_data['foo'], [])
self.assertEqual(len(form.grid_vue_context), 0)
form.add_grid_vue_context(grid)
self.assertEqual(len(form.grid_vue_context), 1)
self.assertIn('foo', form.grid_vue_context)
self.assertEqual(form.grid_vue_context['foo'], {
'data': [],
'row_classes': {},
})
# calling again with same key will replace data
records = [{'foo': 1}, {'foo': 2}]
grid = Grid(self.request, key='foo', columns=['foo'], data=records)
form.add_grid_vue_data(grid)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foo', form.grid_vue_data)
self.assertEqual(form.grid_vue_data['foo'], records)
form.add_grid_vue_context(grid)
self.assertEqual(len(form.grid_vue_context), 1)
self.assertIn('foo', form.grid_vue_context)
self.assertEqual(form.grid_vue_context['foo'], {
'data': records,
'row_classes': {},
})
def test_render_vue_finalize(self):
form = self.make_form()

View file

@ -1286,10 +1286,10 @@ class TestGrid(WebTestCase):
# form will register grid data
form = Form(self.request)
self.assertEqual(len(form.grid_vue_data), 0)
self.assertEqual(len(form.grid_vue_context), 0)
html = grid.render_table_element(form)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foobar', form.grid_vue_data)
self.assertEqual(len(form.grid_vue_context), 1)
self.assertIn('foobar', form.grid_vue_context)
def test_render_vue_finalize(self):
grid = self.make_grid()
@ -1337,6 +1337,40 @@ class TestGrid(WebTestCase):
filters = grid.get_vue_filters()
self.assertEqual(len(filters), 2)
def test_get_vue_context(self):
# empty if no columns defined
grid = self.make_grid()
context = grid.get_vue_context()
self.assertEqual(context, {'data': [], 'row_classes': {}})
# typical data is a list
mydata = [
{'foo': 'bar'},
]
grid = self.make_grid(columns=['foo'], data=mydata)
context = grid.get_vue_context()
self.assertEqual(context, {'data': [{'foo': 'bar'}], 'row_classes': {}})
# if grid has actions, that list may be supplemented
grid.actions.append(mod.GridAction(self.request, 'view', url='/blarg'))
context = grid.get_vue_context()
self.assertIsNot(context['data'], mydata)
self.assertEqual(context, {'data': [{'foo': 'bar', '_action_url_view': '/blarg'}],
'row_classes': {}})
# can override value rendering
grid.set_renderer('foo', lambda record, key, value: "blah blah")
context = grid.get_vue_context()
self.assertEqual(context, {'data': [{'foo': 'blah blah', '_action_url_view': '/blarg'}],
'row_classes': {}})
# can set row class
grid.row_class = 'whatever'
context = grid.get_vue_context()
self.assertEqual(context, {'data': [{'foo': 'blah blah', '_action_url_view': '/blarg'}],
'row_classes': {'0': 'whatever'}})
def test_get_vue_data(self):
# empty if no columns defined
@ -1358,11 +1392,35 @@ class TestGrid(WebTestCase):
self.assertIsNot(data, mydata)
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])
# also can override value rendering
# can override value rendering
grid.set_renderer('foo', lambda record, key, value: "blah blah")
data = grid.get_vue_data()
self.assertEqual(data, [{'foo': 'blah blah', '_action_url_view': '/blarg'}])
def test_get_row_class(self):
model = self.app.model
user = model.User(username='barney', active=True)
self.session.add(user)
self.session.commit()
data = dict(user)
# null by default
grid = self.make_grid()
self.assertIsNone(grid.get_row_class(user, data, 1))
# can use static class
grid.row_class = 'foo'
self.assertEqual(grid.get_row_class(user, data, 1), 'foo')
# can use callable
def status(u, d, i):
if not u.active:
return 'inactive'
grid.row_class = status
self.assertIsNone(grid.get_row_class(user, data, 1))
user.active = False
self.assertEqual(grid.get_row_class(user, data, 1), 'inactive')
def test_get_vue_pager_stats(self):
data = [
{'foo': 1, 'bar': 1},

View file

@ -487,6 +487,20 @@ class TestMasterView(WebTestCase):
grid = view.make_model_grid(session=self.session)
self.assertIs(grid.model_class, model.Setting)
# no row class by default
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
grid = view.make_model_grid(session=self.session)
self.assertIsNone(grid.row_class)
# can specify row class
get_row_class = MagicMock()
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting,
grid_row_class=get_row_class):
grid = view.make_model_grid(session=self.session)
self.assertIs(grid.row_class, get_row_class)
# no actions by default
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):

View file

@ -31,6 +31,17 @@ class TestUserView(WebTestCase):
view.configure_grid(grid)
self.assertTrue(grid.is_linked('person'))
def test_grid_row_class(self):
model = self.app.model
user = model.User(username='barney', active=True)
data = dict(user)
view = self.make_view()
self.assertIsNone(view.grid_row_class(user, data, 1))
user.active = False
self.assertEqual(view.grid_row_class(user, data, 1), 'has-background-warning')
def test_configure_form(self):
model = self.app.model
barney = model.User(username='barney')