1
0
Fork 0

feat: improve widget, rendering for Role notes

This commit is contained in:
Lance Edgar 2024-08-13 16:29:34 -05:00
parent b4b72d92aa
commit bdfa0197b2
11 changed files with 201 additions and 19 deletions

View file

@ -30,10 +30,11 @@ in the namespace:
* :class:`deform:deform.widget.Widget` (base class)
* :class:`deform:deform.widget.TextInputWidget`
* :class:`deform:deform.widget.TextAreaWidget`
* :class:`deform:deform.widget.SelectWidget`
"""
from deform.widget import Widget, TextInputWidget, SelectWidget
from deform.widget import Widget, TextInputWidget, TextAreaWidget, SelectWidget
from webhelpers2.html import HTML
@ -48,6 +49,18 @@ class ObjectRefWidget(SelectWidget):
the form schema; via
:meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`.
In readonly mode, this renders a ``<span>`` tag around the
:attr:`model_instance` (converted to string).
Otherwise it renders a select (dropdown) element allowing user to
choose from available records.
This is a subclass of :class:`deform:deform.widget.SelectWidget`
and uses these Deform templates:
* ``select``
* ``readonly/objectref``
.. attribute:: model_instance
Reference to the model record instance, i.e. the "far side" of
@ -60,23 +73,26 @@ class ObjectRefWidget(SelectWidget):
when the :class:`~wuttaweb.forms.schema.ObjectRef` type
instance (associated with the node) is serialized.
"""
readonly_template = 'readonly/objectref'
def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = request
def serialize(self, field, cstruct, **kw):
class NotesWidget(TextAreaWidget):
"""
Serialize the widget.
Widget for use with "notes" fields.
In readonly mode, returns a ``<span>`` tag around the
:attr:`model_instance` rendered as string.
In readonly mode, this shows the notes with a background to make
them stand out a bit more.
Otherwise renders via the ``deform/select`` template.
Otherwise it effectively shows a ``<textarea>`` input element.
This is a subclass of :class:`deform:deform.widget.TextAreaWidget`
and uses these Deform templates:
* ``textarea``
* ``readonly/notes``
"""
readonly = kw.get('readonly', self.readonly)
if readonly:
obj = field.schema.model_instance
return HTML.tag('span', c=str(obj or ''))
return super().serialize(field, cstruct, **kw)
readonly_template = 'readonly/notes'

View file

@ -24,6 +24,7 @@
Base grid classes
"""
import functools
import json
import logging
@ -81,6 +82,12 @@ class Grid:
model records) or else an object capable of producing such a
list, e.g. SQLAlchemy query.
.. attribute:: renderers
Dict of column (cell) value renderer overrides.
See also :meth:`set_renderer()`.
.. attribute:: actions
List of :class:`GridAction` instances represenging action links
@ -106,6 +113,7 @@ class Grid:
key=None,
columns=None,
data=None,
renderers={},
actions=[],
linked_columns=[],
vue_tagname='wutta-grid',
@ -114,6 +122,7 @@ class Grid:
self.model_class = model_class
self.key = key
self.data = data
self.renderers = renderers or {}
self.actions = actions or []
self.linked_columns = linked_columns or []
self.vue_tagname = vue_tagname
@ -194,6 +203,47 @@ class Grid:
if key in self.columns:
self.columns.remove(key)
def set_renderer(self, key, renderer, **kwargs):
"""
Set/override the value renderer for a column.
:param key: Name of column.
:param renderer: Callable as described below.
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.
But then it also will invoke a renderer override (if defined)
to obtain the "final" cell value.
A renderer must be a callable which accepts 3 args ``(record,
key, value)``:
* ``record`` is the "original" record from :attr:`data`
* ``key`` is the column name
* ``value`` is the JSON-safe cell value
Whatever the renderer returns, is then used as final cell
value. For instance::
from webhelpers2.html import HTML
def render_foo(record, key, value):
return HTML.literal("<p>this is the final cell value</p>")
grid = Grid(columns=['foo', 'bar'])
grid.set_renderer('foo', render_foo)
Renderer overrides are tracked via :attr:`renderers`.
"""
if kwargs:
renderer = functools.partial(renderer, **kwargs)
self.renderers[key] = renderer
def set_link(self, key, link=True):
"""
Explicitly enable or disable auto-link behavior for a given
@ -352,12 +402,18 @@ class Grid:
# we have action(s), so add URL(s) for each record in data
data = []
for i, record in enumerate(original_data):
original_record = record
# convert data if needed, for json compat
record = make_json_safe(record,
# TODO: is this a good idea?
warn=False)
# customize value rendering where applicable
for key in self.renderers:
value = record[key]
record[key] = self.renderers[key](original_record, key, value)
# add action urls to each record
for action in self.actions:
url = action.get_url(record, i)

View file

@ -0,0 +1,7 @@
<div tal:omit-tag="">
<span tal:condition="not cstruct"></span>
<pre tal:condition="cstruct"
class="is-family-sans-serif"
style="white-space: pre-wrap;"
>${cstruct}</pre>
</div>

View file

@ -0,0 +1 @@
<span>${str(field.schema.model_instance or '')}</span>

View file

@ -0,0 +1,11 @@
<div tal:define="name name|field.name;
oid oid|field.oid;
vmodel vmodel|'modelData.'+oid;
rows rows|field.widget.rows;
cols cols|field.widget.cols;"
tal:omit-tag="">
<b-input name="${name}"
v-model="${vmodel}"
type="textarea"
tal:attributes="attributes|field.widget.attributes|{};" />
</div>

View file

@ -28,6 +28,7 @@ import sqlalchemy as sa
from sqlalchemy import orm
from pyramid.renderers import render_to_response
from webhelpers2.html import HTML
from wuttaweb.views import View
from wuttaweb.util import get_form_data, get_model_fields
@ -1032,6 +1033,33 @@ class MasterView(View):
for key in self.get_model_key():
grid.set_link(key)
def grid_render_notes(self, record, key, value, maxlen=100):
"""
Custom grid renderer callable for "notes" fields.
If the given text ``value`` is shorter than ``maxlen``
characters, it is returned as-is.
But if it is longer, then it is truncated and an ellispsis is
added. The resulting ``<span>`` tag is also given a ``title``
attribute with the original (full) text, so that appears on
mouse hover.
To use this feature for your grid::
grid.set_renderer('my_notes_field', self.grid_render_notes)
# you can also override maxlen
grid.set_renderer('my_notes_field', self.grid_render_notes, maxlen=50)
"""
if value is None:
return
if len(value) < maxlen:
return value
return HTML.tag('span', title=value, c=f"{value[:maxlen]}...")
def get_instance(self, session=None):
"""
This should return the "current" model instance based on the

View file

@ -27,6 +27,7 @@ Views for roles
from wuttjamaican.db.model import Role
from wuttaweb.views import MasterView
from wuttaweb.db import Session
from wuttaweb.forms import widgets
class RoleView(MasterView):
@ -62,6 +63,9 @@ class RoleView(MasterView):
# name
g.set_link('name')
# notes
g.set_renderer('notes', self.grid_render_notes)
def configure_form(self, f):
""" """
super().configure_form(f)
@ -73,6 +77,9 @@ class RoleView(MasterView):
# name
f.set_validator('name', self.unique_name)
# notes
f.set_widget('notes', widgets.NotesWidget())
def unique_name(self, node, value):
""" """
model = self.app.model

View file

@ -8,8 +8,15 @@ from wuttaweb.forms import widgets
from wuttaweb.forms.schema import PersonRef
from tests.util import WebTestCase
class TestObjectRefWidget(WebTestCase):
def make_field(self, node, **kwargs):
# TODO: not sure why default renderer is in use even though
# pyramid_deform was included in setup? but this works..
kwargs.setdefault('renderer', deform.Form.default_renderer)
return deform.Field(node, **kwargs)
def test_serialize(self):
model = self.app.model
person = model.Person(full_name="Betty Boop")
@ -19,14 +26,14 @@ class TestObjectRefWidget(WebTestCase):
# standard (editable)
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
widget = widgets.ObjectRefWidget(self.request)
field = deform.Field(node)
field = self.make_field(node)
html = widget.serialize(field, person.uuid)
self.assertIn('<select ', html)
self.assertIn('<b-select ', html)
# readonly
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
node.model_instance = person
widget = widgets.ObjectRefWidget(self.request)
field = deform.Field(node)
field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True)
self.assertEqual(html, '<span>Betty Boop</span>')
self.assertEqual(html.strip(), '<span>Betty Boop</span>')

View file

@ -75,6 +75,25 @@ class TestGrid(TestCase):
grid.remove('two', 'three')
self.assertEqual(grid.columns, ['one', 'four'])
def test_set_renderer(self):
grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.renderers, {})
def render1(record, key, value):
pass
# basic
grid.set_renderer('foo', render1)
self.assertIs(grid.renderers['foo'], render1)
def render2(record, key, value, extra=None):
return extra
# can pass kwargs to get a partial
grid.set_renderer('foo', render2, extra=42)
self.assertIsNot(grid.renderers['foo'], render2)
self.assertEqual(grid.renderers['foo'](None, None, None), 42)
def test_linked_columns(self):
grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.linked_columns, [])
@ -143,6 +162,11 @@ class TestGrid(TestCase):
self.assertIsNot(data, mydata)
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])
# also 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'}])
class TestGridAction(TestCase):

View file

@ -50,11 +50,11 @@ class WebTestCase(DataTestCase):
self.pyramid_config = testing.setUp(request=self.request, settings={
'wutta_config': self.config,
'mako.directories': ['wuttaweb:templates'],
# TODO: have not need this yet, but will?
# 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
})
# init web
self.pyramid_config.include('pyramid_deform')
self.pyramid_config.include('pyramid_mako')
self.pyramid_config.include('wuttaweb.static')
self.pyramid_config.include('wuttaweb.views.essential')

View file

@ -16,6 +16,9 @@ from tests.util import WebTestCase
class TestMasterView(WebTestCase):
def make_view(self):
return master.MasterView(self.request)
def test_defaults(self):
with patch.multiple(master.MasterView, create=True,
model_name='Widget',
@ -399,6 +402,28 @@ class TestMasterView(WebTestCase):
view.configure_grid(grid)
self.assertNotIn('uuid', grid.columns)
def test_grid_render_notes(self):
model = self.app.model
view = self.make_view()
# null
text = None
role = model.Role(name="Foo", notes=text)
value = view.grid_render_notes(role, 'notes', text)
self.assertIsNone(value)
# short string
text = "hello world"
role = model.Role(name="Foo", notes=text)
value = view.grid_render_notes(role, 'notes', text)
self.assertEqual(value, text)
# long string
text = "hello world " * 20
role = model.Role(name="Foo", notes=text)
value = view.grid_render_notes(role, 'notes', text)
self.assertIn('<span ', value)
def test_get_instance(self):
model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar')