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.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'
|
||||||
|
|
|
@ -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)
|
||||||
|
|
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 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>')
|
||||||
|
|
|
@ -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):
|
||||||
|
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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')
|
||||||
|
|
Loading…
Reference in a new issue