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()) self.set_fields(fields or self.get_fields())
# nb. this tracks grid JSON data for inclusion in page template # 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): def __contains__(self, name):
""" """
@ -826,16 +826,16 @@ class Form:
output = render(template, context) output = render(template, context)
return HTML.literal(output) return HTML.literal(output)
def add_grid_vue_data(self, grid): def add_grid_vue_context(self, grid):
""" """ """ """
if not grid.key: if not grid.key:
raise ValueError("grid must have a 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, " log.warning("grid data with key '%s' already registered, "
"but will be replaced", grid.key) "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( def render_vue_field(
self, self,

View file

@ -27,6 +27,7 @@ Base grid classes
import functools import functools
import json import json
import logging import logging
import warnings
from collections import namedtuple from collections import namedtuple
import sqlalchemy as sa import sqlalchemy as sa
@ -116,6 +117,26 @@ class Grid:
See also :meth:`set_renderer()`. 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 .. attribute:: actions
List of :class:`GridAction` instances represenging action links List of :class:`GridAction` instances represenging action links
@ -330,6 +351,7 @@ class Grid:
data=None, data=None,
labels={}, labels={},
renderers={}, renderers={},
row_class=None,
actions=[], actions=[],
linked_columns=[], linked_columns=[],
sortable=False, sortable=False,
@ -355,6 +377,7 @@ class Grid:
self.data = data self.data = data
self.labels = labels or {} self.labels = labels or {}
self.renderers = renderers or {} self.renderers = renderers or {}
self.row_class = row_class
self.actions = actions or [] self.actions = actions or []
self.linked_columns = linked_columns or [] self.linked_columns = linked_columns or []
self.joiners = joiners or {} self.joiners = joiners or {}
@ -530,8 +553,9 @@ class Grid:
Depending on the nature of grid data, sometimes a cell's Depending on the nature of grid data, sometimes a cell's
"as-is" value will be undesirable for display purposes. "as-is" value will be undesirable for display purposes.
The logic in :meth:`get_vue_data()` will first "convert" all The logic in :meth:`get_vue_context()` will first "convert"
grid data as necessary so that it is at least JSON-compatible. all grid data as necessary so that it is at least
JSON-compatible.
But then it also will invoke a renderer override (if defined) But then it also will invoke a renderer override (if defined)
to obtain the "final" cell value. to obtain the "final" cell value.
@ -1670,7 +1694,7 @@ class Grid:
.. code-block:: html .. code-block:: html
<b-table :data="gridData['mykey']"> <b-table :data="gridContext['mykey'].data">
<!-- columns etc. --> <!-- columns etc. -->
</b-table> </b-table>
@ -1689,10 +1713,10 @@ class Grid:
.. note:: .. note::
The above example shows ``gridData['mykey']`` as the Vue The above example shows ``gridContext['mykey'].data`` as
data reference. This should "just work" if you provide the the Vue data reference. This should "just work" if you
correct ``form`` arg and the grid is contained directly by provide the correct ``form`` arg and the grid is contained
that form's Vue component. directly by that form's Vue component.
However, this may not account for all use cases. For now However, this may not account for all use cases. For now
we wait and see what comes up, but know the dust may not 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 # nb. must register data for inclusion on page template
if form: if form:
form.add_grid_vue_data(self) form.add_grid_vue_context(self)
# otherwise logic is the same, just different template # otherwise logic is the same, just different template
return self.render_vue_template(template=template, **context) return self.render_vue_template(template=template, **context)
@ -1809,7 +1833,7 @@ class Grid:
in its `Table docs in its `Table docs
<https://buefy.org/documentation/table/#api-view>`_. <https://buefy.org/documentation/table/#api-view>`_.
See also :meth:`get_vue_data()`. See also :meth:`get_vue_context()`.
""" """
if not self.columns: if not self.columns:
raise ValueError(f"you must define columns for the grid! key = {self.key}") raise ValueError(f"you must define columns for the grid! key = {self.key}")
@ -1869,54 +1893,46 @@ class Grid:
}) })
return filters 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 * ``data`` - list of Vue-compatible data records
result, e.g. to add URLs for :attr:`actions` etc. * ``row_classes`` - dict of per-row CSS classes
Importantly, this also ensures each value in the dict is This first calls :meth:`get_visible_data()` to get the
JSON-serializable, using original data set. Each record is converted to a dict.
:func:`~wuttaweb.util.make_json_safe()`.
:returns: List of data record dicts for use with Vue table Then it calls :func:`~wuttaweb.util.make_json_safe()` to
component. May be the full set of data, or just the ensure each record can be serialized to JSON.
current page, per :attr:`paginate_on_backend`.
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() original_data = self.get_visible_data()
# TODO: at some point i thought it was useful to wrangle the # loop thru data
# 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
data = [] data = []
for i, record in enumerate(original_data): row_classes = {}
for i, record in enumerate(original_data, 1):
original_record = record original_record = record
# convert record to new dict
record = dict(record) record = dict(record)
# convert data if needed, for json compat # make all values safe for json
record = make_json_safe(record, record = make_json_safe(record, warn=False)
# TODO: is this a good idea?
warn=False)
# customize value rendering where applicable # customize value rendering where applicable
for key in self.renderers: for key in self.renderers:
@ -1931,9 +1947,48 @@ class Grid:
if url: if url:
record[key] = 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) 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): def get_vue_pager_stats(self):
""" """
@ -2086,7 +2141,7 @@ class GridAction:
:param obj: Model instance of whatever type the parent grid is :param obj: Model instance of whatever type the parent grid is
setup to use. 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. parent grid.
See also :attr:`url`. See also :attr:`url`.

View file

@ -69,9 +69,9 @@
% endif % endif
% if form.grid_vue_data: % if form.grid_vue_context:
gridData: { gridContext: {
% for key, data in form.grid_vue_data.items(): % for key, data in form.grid_vue_context.items():
'${key}': ${json.dumps(data)|n}, '${key}': ${json.dumps(data)|n},
% endfor % endfor
}, },

View file

@ -1,5 +1,5 @@
## -*- coding: utf-8; -*- ## -*- coding: utf-8; -*-
<${b}-table :data="gridData['${grid.key}']"> <${b}-table :data="gridContext['${grid.key}'].data">
% for column in grid.get_vue_columns(): % for column in grid.get_vue_columns():
<${b}-table-column field="${column['field']}" <${b}-table-column field="${column['field']}"

View file

@ -93,6 +93,7 @@
</div> </div>
<${b}-table :data="data" <${b}-table :data="data"
:row-class="getRowClass"
:loading="loading" :loading="loading"
narrowed narrowed
hoverable hoverable
@ -227,10 +228,12 @@
<script> <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 = { const ${grid.vue_component}Data = {
data: ${grid.vue_component}CurrentData, data: ${grid.vue_component}CurrentData,
rowClasses: ${grid.vue_component}Context.row_classes,
loading: false, loading: false,
## nb. this tracks whether grid.fetchFirstData() happened ## 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) { renderNumber(value) {
if (value != undefined) { if (value != undefined) {
return value.toLocaleString('en') return value.toLocaleString('en')
@ -457,6 +465,7 @@
if (!response.data.error) { if (!response.data.error) {
${grid.vue_component}CurrentData = response.data.data ${grid.vue_component}CurrentData = response.data.data
this.data = ${grid.vue_component}CurrentData this.data = ${grid.vue_component}CurrentData
this.rowClasses = response.data.row_classes || {}
% if grid.paginated and grid.paginate_on_backend: % if grid.paginated and grid.paginate_on_backend:
this.pagerStats = response.data.pager_stats this.pagerStats = response.data.pager_stats
% endif % endif

View file

@ -181,6 +181,16 @@ class MasterView(View):
This is optional; see also :meth:`get_grid_columns()`. 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 .. attribute:: filterable
Boolean indicating whether the grid for the :meth:`index()` Boolean indicating whether the grid for the :meth:`index()`
@ -360,7 +370,7 @@ class MasterView(View):
if self.request.GET.get('partial'): if self.request.GET.get('partial'):
# so-called 'partial' requests get just data, no html # 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: if grid.paginated and grid.paginate_on_backend:
context['pager_stats'] = grid.get_vue_pager_stats() context['pager_stats'] = grid.get_vue_pager_stats()
return self.json_response(context) return self.json_response(context)
@ -1240,6 +1250,8 @@ class MasterView(View):
kwargs['actions'] = actions kwargs['actions'] = actions
if hasattr(self, 'grid_row_class'):
kwargs.setdefault('row_class', self.grid_row_class)
kwargs.setdefault('filterable', self.filterable) kwargs.setdefault('filterable', self.filterable)
kwargs.setdefault('filter_defaults', self.filter_defaults) kwargs.setdefault('filter_defaults', self.filter_defaults)
kwargs.setdefault('sortable', self.sortable) kwargs.setdefault('sortable', self.sortable)

View file

@ -57,19 +57,24 @@ class UserView(MasterView):
filter_defaults = { filter_defaults = {
'username': {'active': True}, 'username': {'active': True},
'active': {'active': True, 'verb': 'is_true'},
} }
sort_defaults = 'username' sort_defaults = 'username'
# TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None): def get_query(self, session=None):
""" """ """ """
model = self.app.model
query = super().get_query(session=session) 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): def configure_grid(self, g):
""" """ """ """
super().configure_grid(g) super().configure_grid(g)
model = self.app.model
# never show these # never show these
g.remove('person_uuid', g.remove('person_uuid',
@ -81,6 +86,14 @@ class UserView(MasterView):
# person # person
g.set_link('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): 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.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
self.assertNotIn('@submit', html) self.assertNotIn('@submit', html)
def test_add_grid_vue_data(self): def test_add_grid_vue_context(self):
form = self.make_form() form = self.make_form()
# grid must have key # grid must have key
grid = Grid(self.request) 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 # otherwise it works
grid = Grid(self.request, key='foo') grid = Grid(self.request, key='foo')
self.assertEqual(len(form.grid_vue_data), 0) self.assertEqual(len(form.grid_vue_context), 0)
form.add_grid_vue_data(grid) form.add_grid_vue_context(grid)
self.assertEqual(len(form.grid_vue_data), 1) self.assertEqual(len(form.grid_vue_context), 1)
self.assertIn('foo', form.grid_vue_data) self.assertIn('foo', form.grid_vue_context)
self.assertEqual(form.grid_vue_data['foo'], []) self.assertEqual(form.grid_vue_context['foo'], {
'data': [],
'row_classes': {},
})
# calling again with same key will replace data # calling again with same key will replace data
records = [{'foo': 1}, {'foo': 2}] records = [{'foo': 1}, {'foo': 2}]
grid = Grid(self.request, key='foo', columns=['foo'], data=records) grid = Grid(self.request, key='foo', columns=['foo'], data=records)
form.add_grid_vue_data(grid) form.add_grid_vue_context(grid)
self.assertEqual(len(form.grid_vue_data), 1) self.assertEqual(len(form.grid_vue_context), 1)
self.assertIn('foo', form.grid_vue_data) self.assertIn('foo', form.grid_vue_context)
self.assertEqual(form.grid_vue_data['foo'], records) self.assertEqual(form.grid_vue_context['foo'], {
'data': records,
'row_classes': {},
})
def test_render_vue_finalize(self): def test_render_vue_finalize(self):
form = self.make_form() form = self.make_form()

View file

@ -1286,10 +1286,10 @@ class TestGrid(WebTestCase):
# form will register grid data # form will register grid data
form = Form(self.request) 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) html = grid.render_table_element(form)
self.assertEqual(len(form.grid_vue_data), 1) self.assertEqual(len(form.grid_vue_context), 1)
self.assertIn('foobar', form.grid_vue_data) self.assertIn('foobar', form.grid_vue_context)
def test_render_vue_finalize(self): def test_render_vue_finalize(self):
grid = self.make_grid() grid = self.make_grid()
@ -1337,6 +1337,40 @@ class TestGrid(WebTestCase):
filters = grid.get_vue_filters() filters = grid.get_vue_filters()
self.assertEqual(len(filters), 2) 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): def test_get_vue_data(self):
# empty if no columns defined # empty if no columns defined
@ -1358,11 +1392,35 @@ class TestGrid(WebTestCase):
self.assertIsNot(data, mydata) self.assertIsNot(data, mydata)
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}]) 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") grid.set_renderer('foo', lambda record, key, value: "blah blah")
data = grid.get_vue_data() data = grid.get_vue_data()
self.assertEqual(data, [{'foo': 'blah blah', '_action_url_view': '/blarg'}]) 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): def test_get_vue_pager_stats(self):
data = [ data = [
{'foo': 1, 'bar': 1}, {'foo': 1, 'bar': 1},

View file

@ -487,6 +487,20 @@ class TestMasterView(WebTestCase):
grid = view.make_model_grid(session=self.session) grid = view.make_model_grid(session=self.session)
self.assertIs(grid.model_class, model.Setting) 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 # no actions by default
with patch.multiple(mod.MasterView, create=True, with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting): model_class=model.Setting):

View file

@ -31,6 +31,17 @@ class TestUserView(WebTestCase):
view.configure_grid(grid) view.configure_grid(grid)
self.assertTrue(grid.is_linked('person')) 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): def test_configure_form(self):
model = self.app.model model = self.app.model
barney = model.User(username='barney') barney = model.User(username='barney')