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 = {} kwargs = {}
if self.model_instance: if self.model_instance:
# TODO: would it be smarter to test with hasattr() ?
# if hasattr(schema, 'dictify'): # TODO: i keep finding problems with this, not sure
if isinstance(self.model_instance, model.Base): # 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) kwargs['appstruct'] = schema.dictify(self.model_instance)
else: else:
kwargs['appstruct'] = self.model_instance 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 # nb. must give a reference back to wutta form; this is
# for sake of field schema nodes and widgets, e.g. to # for sake of field schema nodes and widgets, e.g. to
# access the main model instance # access the main model instance
form = deform.Form(schema, **kwargs)
form.wutta_form = self form.wutta_form = self
self.deform_form = form self.deform_form = form
@ -922,18 +944,13 @@ class Form:
if field_type: if field_type:
attrs['type'] = field_type attrs['type'] = field_type
if messages: if messages:
if len(messages) == 1: cls = 'is-size-7'
msg = messages[0] if field_type == 'is-danger':
if msg.startswith('`') and msg.endswith('`'): cls += ' has-text-danger'
attrs[':message'] = msg messages = [HTML.tag('p', c=[msg], class_=cls)
else: for msg in messages]
attrs['message'] = msg slot = HTML.tag('slot', name='messages', c=messages)
# TODO html = HTML.tag('div', c=[html, slot])
# else:
# # nb. must pass an array as JSON string
# attrs[':message'] = '[{}]'.format(', '.join([
# "'{}'".format(msg.replace("'", r"\'"))
# for msg in messages]))
return HTML.tag('b-field', c=[html], **attrs) return HTML.tag('b-field', c=[html], **attrs)
@ -1076,7 +1093,7 @@ class Form:
""" """
dform = self.get_deform() dform = self.get_deform()
if field in dform: if field in dform:
error = dform[field].errormsg field = dform[field]
if error: if field.error:
return [error] return field.error.messages()
return [] return []

View file

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

View file

@ -1047,7 +1047,7 @@ class Grid:
filters = filters or {} filters = filters or {}
if self.model_class: if self.model_class:
for key in self.columns: for key in self.get_model_columns():
if key in filters: if key in filters:
continue continue
prop = getattr(self.model_class, key, None) 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()" <b-button @click="copyDirectLink()"
title="Copy grid link to clipboard" title="Copy grid link to clipboard"
:is-small="smallFilters"> :is-small="smallFilters">
<b-icon pack="fas" icon="share-alt" /> <b-icon pack="fas" icon="share" />
</b-button> </b-button>
<b-button type="is-primary" <b-button type="is-primary"

View file

@ -989,6 +989,84 @@ class MasterView(View):
self.app.save_setting(session, setting['name'], setting['value'], self.app.save_setting(session, setting['name'], setting['value'],
force_create=True) 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 # support methods
############################## ##############################
@ -1317,9 +1395,8 @@ class MasterView(View):
Default logic for this method returns a "plain" query on the Default logic for this method returns a "plain" query on the
:attr:`model_class` if that is defined; otherwise ``None``. :attr:`model_class` if that is defined; otherwise ``None``.
""" """
model = self.app.model
model_class = self.get_model_class() model_class = self.get_model_class()
if model_class and issubclass(model_class, model.Base): if model_class:
session = session or self.Session() session = session or self.Session()
return session.query(model_class) return session.query(model_class)
@ -1344,33 +1421,6 @@ 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

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

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
import decimal
import functools import functools
from unittest import TestCase from unittest import TestCase
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -558,6 +559,44 @@ 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_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): def test_grid_render_notes(self):
model = self.app.model model = self.app.model
view = self.make_view() view = self.make_view()