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.Widget` (base class)
* :class:`deform:deform.widget.TextInputWidget` * :class:`deform:deform.widget.TextInputWidget`
* :class:`deform:deform.widget.TextAreaWidget`
* :class:`deform:deform.widget.SelectWidget` * :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 from webhelpers2.html import HTML
@ -48,6 +49,18 @@ class ObjectRefWidget(SelectWidget):
the form schema; via the form schema; via
:meth:`~wuttaweb.forms.schema.ObjectRef.widget_maker()`. :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 .. attribute:: model_instance
Reference to the model record instance, i.e. the "far side" of 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 when the :class:`~wuttaweb.forms.schema.ObjectRef` type
instance (associated with the node) is serialized. instance (associated with the node) is serialized.
""" """
readonly_template = 'readonly/objectref'
def __init__(self, request, *args, **kwargs): def __init__(self, request, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.request = request self.request = request
def serialize(self, field, cstruct, **kw):
"""
Serialize the widget.
In readonly mode, returns a ``<span>`` tag around the class NotesWidget(TextAreaWidget):
:attr:`model_instance` rendered as string. """
Widget for use with "notes" fields.
Otherwise renders via the ``deform/select`` template. In readonly mode, this shows the notes with a background to make
""" them stand out a bit more.
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) 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_template = 'readonly/notes'

View file

@ -24,6 +24,7 @@
Base grid classes Base grid classes
""" """
import functools
import json import json
import logging import logging
@ -81,6 +82,12 @@ class Grid:
model records) or else an object capable of producing such a model records) or else an object capable of producing such a
list, e.g. SQLAlchemy query. list, e.g. SQLAlchemy query.
.. attribute:: renderers
Dict of column (cell) value renderer overrides.
See also :meth:`set_renderer()`.
.. attribute:: actions .. attribute:: actions
List of :class:`GridAction` instances represenging action links List of :class:`GridAction` instances represenging action links
@ -106,6 +113,7 @@ class Grid:
key=None, key=None,
columns=None, columns=None,
data=None, data=None,
renderers={},
actions=[], actions=[],
linked_columns=[], linked_columns=[],
vue_tagname='wutta-grid', vue_tagname='wutta-grid',
@ -114,6 +122,7 @@ class Grid:
self.model_class = model_class self.model_class = model_class
self.key = key self.key = key
self.data = data self.data = data
self.renderers = renderers or {}
self.actions = actions or [] self.actions = actions or []
self.linked_columns = linked_columns or [] self.linked_columns = linked_columns or []
self.vue_tagname = vue_tagname self.vue_tagname = vue_tagname
@ -194,6 +203,47 @@ class Grid:
if key in self.columns: if key in self.columns:
self.columns.remove(key) 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): def set_link(self, key, link=True):
""" """
Explicitly enable or disable auto-link behavior for a given 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 # we have action(s), so add URL(s) for each record in data
data = [] data = []
for i, record in enumerate(original_data): for i, record in enumerate(original_data):
original_record = record
# convert data if needed, for json compat # convert data if needed, for json compat
record = make_json_safe(record, record = make_json_safe(record,
# TODO: is this a good idea? # TODO: is this a good idea?
warn=False) 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 # add action urls to each record
for action in self.actions: for action in self.actions:
url = action.get_url(record, i) 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 sqlalchemy import orm
from pyramid.renderers import render_to_response from pyramid.renderers import render_to_response
from webhelpers2.html import HTML
from wuttaweb.views import View from wuttaweb.views import View
from wuttaweb.util import get_form_data, get_model_fields from wuttaweb.util import get_form_data, get_model_fields
@ -1032,6 +1033,33 @@ class MasterView(View):
for key in self.get_model_key(): for key in self.get_model_key():
grid.set_link(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): def get_instance(self, session=None):
""" """
This should return the "current" model instance based on the 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 wuttjamaican.db.model import Role
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttaweb.forms import widgets
class RoleView(MasterView): class RoleView(MasterView):
@ -62,6 +63,9 @@ class RoleView(MasterView):
# name # name
g.set_link('name') g.set_link('name')
# notes
g.set_renderer('notes', self.grid_render_notes)
def configure_form(self, f): def configure_form(self, f):
""" """ """ """
super().configure_form(f) super().configure_form(f)
@ -73,6 +77,9 @@ class RoleView(MasterView):
# name # name
f.set_validator('name', self.unique_name) f.set_validator('name', self.unique_name)
# notes
f.set_widget('notes', widgets.NotesWidget())
def unique_name(self, node, value): def unique_name(self, node, value):
""" """ """ """
model = self.app.model model = self.app.model

View file

@ -8,8 +8,15 @@ from wuttaweb.forms import widgets
from wuttaweb.forms.schema import PersonRef from wuttaweb.forms.schema import PersonRef
from tests.util import WebTestCase from tests.util import WebTestCase
class TestObjectRefWidget(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): def test_serialize(self):
model = self.app.model model = self.app.model
person = model.Person(full_name="Betty Boop") person = model.Person(full_name="Betty Boop")
@ -19,14 +26,14 @@ class TestObjectRefWidget(WebTestCase):
# standard (editable) # standard (editable)
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request, session=self.session))
widget = widgets.ObjectRefWidget(self.request) widget = widgets.ObjectRefWidget(self.request)
field = deform.Field(node) field = self.make_field(node)
html = widget.serialize(field, person.uuid) html = widget.serialize(field, person.uuid)
self.assertIn('<select ', html) self.assertIn('<b-select ', html)
# readonly # readonly
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request, session=self.session))
node.model_instance = person node.model_instance = person
widget = widgets.ObjectRefWidget(self.request) widget = widgets.ObjectRefWidget(self.request)
field = deform.Field(node) field = self.make_field(node)
html = widget.serialize(field, person.uuid, readonly=True) 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') grid.remove('two', 'three')
self.assertEqual(grid.columns, ['one', 'four']) 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): def test_linked_columns(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
self.assertEqual(grid.linked_columns, []) self.assertEqual(grid.linked_columns, [])
@ -143,6 +162,11 @@ class TestGrid(TestCase):
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
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): class TestGridAction(TestCase):

View file

@ -50,11 +50,11 @@ class WebTestCase(DataTestCase):
self.pyramid_config = testing.setUp(request=self.request, settings={ self.pyramid_config = testing.setUp(request=self.request, settings={
'wutta_config': self.config, 'wutta_config': self.config,
'mako.directories': ['wuttaweb:templates'], '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 # init web
self.pyramid_config.include('pyramid_deform')
self.pyramid_config.include('pyramid_mako') self.pyramid_config.include('pyramid_mako')
self.pyramid_config.include('wuttaweb.static') self.pyramid_config.include('wuttaweb.static')
self.pyramid_config.include('wuttaweb.views.essential') self.pyramid_config.include('wuttaweb.views.essential')

View file

@ -16,6 +16,9 @@ from tests.util import WebTestCase
class TestMasterView(WebTestCase): class TestMasterView(WebTestCase):
def make_view(self):
return master.MasterView(self.request)
def test_defaults(self): def test_defaults(self):
with patch.multiple(master.MasterView, create=True, with patch.multiple(master.MasterView, create=True,
model_name='Widget', model_name='Widget',
@ -399,6 +402,28 @@ class TestMasterView(WebTestCase):
view.configure_grid(grid) view.configure_grid(grid)
self.assertNotIn('uuid', grid.columns) 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): def test_get_instance(self):
model = self.app.model model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar') self.app.save_setting(self.session, 'foo', 'bar')