1
0
Fork 0

fix: misc. improvements for display of grids, form errors

This commit is contained in:
Lance Edgar 2024-08-23 19:23:40 -05:00
parent bf2ca4b475
commit 2503836ef5
8 changed files with 183 additions and 70 deletions

View file

@ -746,17 +746,39 @@ class Form:
kwargs = {}
if self.model_instance:
# TODO: would it be smarter to test with hasattr() ?
# if hasattr(schema, 'dictify'):
if isinstance(self.model_instance, model.Base):
# TODO: i keep finding problems with this, not sure
# what needs to happen. some forms will have a simple
# dict for model_instance, others will have a proper
# SQLAlchemy object. and in the latter case, it may
# not be "wutta-native" but from another DB.
# so the problem is, how to detect whether we should
# use the model_instance as-is or if we should convert
# to a dict. some options include:
# - check if instance has dictify() method
# i *think* this was tried and didn't work? but do not recall
# - check if is instance of model.Base
# this is unreliable since model.Base is wutta-native
# - check if form has a model_class
# has not been tried yet
# - check if schema is from colanderalchemy
# this is what we are trying currently...
if isinstance(schema, SQLAlchemySchemaNode):
kwargs['appstruct'] = schema.dictify(self.model_instance)
else:
kwargs['appstruct'] = self.model_instance
form = deform.Form(schema, **kwargs)
# create the Deform instance
# nb. must give a reference back to wutta form; this is
# for sake of field schema nodes and widgets, e.g. to
# access the main model instance
form = deform.Form(schema, **kwargs)
form.wutta_form = self
self.deform_form = form
@ -922,18 +944,13 @@ class Form:
if field_type:
attrs['type'] = field_type
if messages:
if len(messages) == 1:
msg = messages[0]
if msg.startswith('`') and msg.endswith('`'):
attrs[':message'] = msg
else:
attrs['message'] = msg
# TODO
# else:
# # nb. must pass an array as JSON string
# attrs[':message'] = '[{}]'.format(', '.join([
# "'{}'".format(msg.replace("'", r"\'"))
# for msg in messages]))
cls = 'is-size-7'
if field_type == 'is-danger':
cls += ' has-text-danger'
messages = [HTML.tag('p', c=[msg], class_=cls)
for msg in messages]
slot = HTML.tag('slot', name='messages', c=messages)
html = HTML.tag('div', c=[html, slot])
return HTML.tag('b-field', c=[html], **attrs)
@ -1076,7 +1093,7 @@ class Form:
"""
dform = self.get_deform()
if field in dform:
error = dform[field].errormsg
if error:
return [error]
field = dform[field]
if field.error:
return field.error.messages()
return []

View file

@ -35,12 +35,14 @@ in the namespace:
* :class:`deform:deform.widget.CheckedPasswordWidget`
* :class:`deform:deform.widget.SelectWidget`
* :class:`deform:deform.widget.CheckboxChoiceWidget`
* :class:`deform:deform.widget.MoneyInputWidget`
"""
import colander
from deform.widget import (Widget, TextInputWidget, TextAreaWidget,
PasswordWidget, CheckedPasswordWidget,
SelectWidget, CheckboxChoiceWidget)
SelectWidget, CheckboxChoiceWidget,
MoneyInputWidget)
from webhelpers2.html import HTML
from wuttaweb.db import Session

View file

@ -1047,7 +1047,7 @@ class Grid:
filters = filters or {}
if self.model_class:
for key in self.columns:
for key in self.get_model_columns():
if key in filters:
continue
prop = getattr(self.model_class, key, None)

View file

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

View file

@ -19,7 +19,7 @@
<b-button @click="copyDirectLink()"
title="Copy grid link to clipboard"
:is-small="smallFilters">
<b-icon pack="fas" icon="share-alt" />
<b-icon pack="fas" icon="share" />
</b-button>
<b-button type="is-primary"

View file

@ -989,6 +989,84 @@ class MasterView(View):
self.app.save_setting(session, setting['name'], setting['value'],
force_create=True)
##############################
# grid rendering methods
##############################
def grid_render_bool(self, record, key, value):
"""
Custom grid value renderer for "boolean" fields.
This converts a bool value to "Yes" or "No" - unless the value
is ``None`` in which case this renders empty string.
To use this feature for your grid::
grid.set_renderer('my_bool_field', self.grid_render_bool)
"""
if value is None:
return
return "Yes" if value else "No"
def grid_render_currency(self, record, key, value, scale=2):
"""
Custom grid value renderer for "currency" fields.
This expects float or decimal values, and will round the
decimal as appropriate, and add the currency symbol.
:param scale: Number of decimal digits to be displayed;
default is 2 places.
To use this feature for your grid::
grid.set_renderer('my_currency_field', self.grid_render_currency)
# you can also override scale
grid.set_renderer('my_currency_field', self.grid_render_currency, scale=4)
"""
# nb. get new value since the one provided will just be a
# (json-safe) *string* if the original type was Decimal
value = record[key]
if value is None:
return
if value < 0:
fmt = f"(${{:0,.{scale}f}})"
return fmt.format(0 - value)
fmt = f"${{:0,.{scale}f}}"
return fmt.format(value)
def grid_render_notes(self, record, key, value, maxlen=100):
"""
Custom grid value renderer 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]}...")
##############################
# support methods
##############################
@ -1317,9 +1395,8 @@ class MasterView(View):
Default logic for this method returns a "plain" query on the
:attr:`model_class` if that is defined; otherwise ``None``.
"""
model = self.app.model
model_class = self.get_model_class()
if model_class and issubclass(model_class, model.Base):
if model_class:
session = session or self.Session()
return session.query(model_class)
@ -1344,33 +1421,6 @@ 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

@ -461,15 +461,10 @@ class TestForm(TestCase):
# nb. no error message
self.assertNotIn('message', html)
# with single "static" error
dform['foo'].error = MagicMock(msg="something is wrong")
# with error message
with patch.object(form, 'get_field_errors', return_value=['something is wrong']):
html = form.render_vue_field('foo')
self.assertIn(' message="something is wrong"', html)
# with single "dynamic" error
dform['foo'].error = MagicMock(msg="`something is wrong`")
html = form.render_vue_field('foo')
self.assertIn(':message="`something is wrong`"', html)
self.assertIn('something is wrong', html)
# add another field, but not to deform, so it should still
# display but with no widget
@ -527,18 +522,21 @@ class TestForm(TestCase):
def test_get_field_errors(self):
schema = self.make_schema()
# simple 'Required' validation failure
form = self.make_form(schema=schema)
dform = form.get_deform()
self.request.method = 'POST'
self.request.POST = {'foo': 'one'}
self.assertFalse(form.validate())
errors = form.get_field_errors('bar')
self.assertEqual(errors, ['Required'])
# no error
errors = form.get_field_errors('foo')
self.assertEqual(len(errors), 0)
# simple error
dform['foo'].error = MagicMock(msg="something is wrong")
errors = form.get_field_errors('foo')
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0], "something is wrong")
# no errors
form = self.make_form(schema=schema)
self.request.POST = {'foo': 'one', 'bar': 'two'}
self.assertTrue(form.validate())
errors = form.get_field_errors('bar')
self.assertEqual(errors, [])
def test_validate(self):
schema = self.make_schema()

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8; -*-
import decimal
import functools
from unittest import TestCase
from unittest.mock import MagicMock, patch
@ -558,6 +559,44 @@ class TestMasterView(WebTestCase):
view.configure_grid(grid)
self.assertNotIn('uuid', grid.columns)
def test_grid_render_bool(self):
model = self.app.model
view = self.make_view()
user = model.User(username='barney', active=None)
# null
value = view.grid_render_bool(user, 'active', None)
self.assertIsNone(value)
# true
user.active = True
value = view.grid_render_bool(user, 'active', True)
self.assertEqual(value, "Yes")
# false
user.active = False
value = view.grid_render_bool(user, 'active', False)
self.assertEqual(value, "No")
def test_grid_render_currency(self):
model = self.app.model
view = self.make_view()
obj = {'amount': None}
# null
value = view.grid_render_currency(obj, 'amount', None)
self.assertIsNone(value)
# normal amount
obj['amount'] = decimal.Decimal('100.42')
value = view.grid_render_currency(obj, 'amount', '100.42')
self.assertEqual(value, "$100.42")
# negative amount
obj['amount'] = decimal.Decimal('-100.42')
value = view.grid_render_currency(obj, 'amount', '-100.42')
self.assertEqual(value, "($100.42)")
def test_grid_render_notes(self):
model = self.app.model
view = self.make_view()