feat: improve widget, rendering for Role notes
This commit is contained in:
parent
b4b72d92aa
commit
bdfa0197b2
|
@ -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'
|
||||
|
|
|
@ -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)
|
||||
|
|
7
src/wuttaweb/templates/deform/readonly/notes.pt
Normal file
7
src/wuttaweb/templates/deform/readonly/notes.pt
Normal 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>
|
1
src/wuttaweb/templates/deform/readonly/objectref.pt
Normal file
1
src/wuttaweb/templates/deform/readonly/objectref.pt
Normal file
|
@ -0,0 +1 @@
|
|||
<span>${str(field.schema.model_instance or '')}</span>
|
11
src/wuttaweb/templates/deform/textarea.pt
Normal file
11
src/wuttaweb/templates/deform/textarea.pt
Normal 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>
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>')
|
||||
|
|
|
@ -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):
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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')
|
||||
|
|
Loading…
Reference in a new issue