3
0
Fork 0

Compare commits

...

7 commits

Author SHA1 Message Date
Lance Edgar eda2326a97 fix: add way to set field widgets using pseudo-type
more to come on this idea hopefully..i think it's a good pattern?
2024-12-11 23:05:25 -06:00
Lance Edgar bf8397ba23 fix: add support for date, datetime form fields
using buefy-based picker widgets etc.
2024-12-11 22:38:51 -06:00
Lance Edgar fce1bf9de4 fix: make dropdown widgets as wide as other text fields in main form 2024-12-11 22:30:03 -06:00
Lance Edgar 7fc5d84abd fix: add fallback instance title
otherwise the hero bar can be hidden altogether
2024-12-11 22:29:20 -06:00
Lance Edgar 6e4f390f3f fix: display "global" errors at top of form, if present
this probably could use more work, good enough for now
2024-12-11 22:28:44 -06:00
Lance Edgar 448dc9fc79 fix: add make_form() and make_grid() methods on web handler
to allow override
2024-12-11 22:06:33 -06:00
Lance Edgar 74e2a4f0e2 fix: correct "empty option" behavior for ObjectRef schema type 2024-12-11 09:58:02 -06:00
18 changed files with 392 additions and 12 deletions

View file

@ -33,6 +33,7 @@ intersphinx_mapping = {
'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None),
'python': ('https://docs.python.org/3/', None),
'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', None),
'sqlalchemy': ('http://docs.sqlalchemy.org/en/latest/', None),
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
'wutta-continuum': ('https://rattailproject.org/docs/wutta-continuum/', None),

View file

@ -457,23 +457,62 @@ class Form:
if self.schema:
self.schema[key] = node
def set_widget(self, key, widget):
def set_widget(self, key, widget, **kwargs):
"""
Set/override the widget for a field.
You can specify a widget instance or else a named "type" of
widget, in which case that is passed along to
:meth:`make_widget()`.
:param key: Name of field.
:param widget: Instance of
:class:`deform:deform.widget.Widget`.
:param widget: Either a :class:`deform:deform.widget.Widget`
instance, or else a widget "type" name.
:param \**kwargs: Any remaining kwargs are passed along to
:meth:`make_widget()` - if applicable.
Widget overrides are tracked via :attr:`widgets`.
"""
if not isinstance(widget, deform.widget.Widget):
widget_obj = self.make_widget(widget, **kwargs)
if not widget_obj:
raise ValueError(f"widget type not supported: {widget}")
widget = widget_obj
self.widgets[key] = widget
# update schema if necessary
if self.schema and key in self.schema:
self.schema[key].widget = widget
def make_widget(self, widget_type, **kwargs):
"""
Make and return a new field widget of the given type.
This has built-in support for the following types (although
subclass can override as needed):
* ``'notes'`` => :class:`~wuttaweb.forms.widgets.NotesWidget`
See also :meth:`set_widget()` which may call this method
automatically.
:param widget_type: Which of the above (or custom) widget
type to create.
:param \**kwargs: Remaining kwargs are passed as-is to the
widget factory.
:returns: New widget instance, or ``None`` if e.g. it could
not determine how to create the widget.
"""
from wuttaweb.forms import widgets
if widget_type == 'notes':
return widgets.NotesWidget(**kwargs)
def set_grid(self, key, grid):
"""
Establish a :term:`grid` to be displayed for a field. This
@ -1111,6 +1150,32 @@ class Form:
return self.validated
def has_global_errors(self):
"""
Convenience function to check if the form has any "global"
(not field-level) errors.
See also :meth:`get_global_errors()`.
:returns: ``True`` if global errors present, else ``False``.
"""
dform = self.get_deform()
return bool(dform.error)
def get_global_errors(self):
"""
Returns a list of "global" (not field-level) error messages
for the form.
See also :meth:`has_global_errors()`.
:returns: List of error messages (possibly empty).
"""
dform = self.get_deform()
if dform.error is None:
return []
return dform.error.messages()
def get_field_errors(self, field):
"""
Return a list of error messages for the given field.

View file

@ -24,15 +24,40 @@
Form schema types
"""
import datetime
import uuid as _uuid
import colander
import sqlalchemy as sa
from wuttaweb.db import Session
from wuttaweb.forms import widgets
from wuttjamaican.db.model import Person
class WuttaDateTime(colander.DateTime):
"""
Custom schema type for ``datetime`` fields.
This should be used automatically for
:class:`sqlalchemy:sqlalchemy.types.DateTime` columns unless you
register another default.
This schema type exists for sake of convenience, when working with
the Buefy datepicker + timepicker widgets.
"""
def deserialize(self, node, cstruct):
""" """
if not cstruct:
return colander.null
try:
return datetime.datetime.strptime(cstruct, '%Y-%m-%dT%I:%M %p')
except:
node.raise_invalid("Invalid date and/or time")
class ObjectNode(colander.SchemaNode):
"""
Custom schema node class which adds methods for compatibility with
@ -207,6 +232,11 @@ class ObjectRef(colander.SchemaType):
def serialize(self, node, appstruct):
""" """
# nb. normalize to empty option if no object ref, so that
# works as expected
if self.empty_option and not appstruct:
return self.empty_option[0]
if appstruct is colander.null:
return colander.null
@ -497,3 +527,7 @@ class FileDownload(colander.String):
""" """
kwargs.setdefault('url', self.url)
return widgets.FileDownloadWidget(self.request, **kwargs)
# nb. colanderalchemy schema overrides
sa.DateTime.__colanderalchemy_config__ = {'typ': WuttaDateTime}

View file

@ -102,7 +102,7 @@ class ObjectRefWidget(SelectWidget):
# add url, only if rendering readonly
readonly = kw.get('readonly', self.readonly)
if readonly:
if 'url' not in values and self.url and field.schema.model_instance:
if 'url' not in values and self.url and hasattr(field.schema, 'model_instance'):
values['url'] = self.url(field.schema.model_instance)
return values

View file

@ -26,7 +26,7 @@ Web Handler
from wuttjamaican.app import GenericHandler
from wuttaweb import static
from wuttaweb import static, forms, grids
class WebHandler(GenericHandler):
@ -122,3 +122,23 @@ class WebHandler(GenericHandler):
default='wuttaweb.menus:MenuHandler')
self.menu_handler = self.app.load_object(spec)(self.config)
return self.menu_handler
def make_form(self, request, **kwargs):
"""
Make and return a new :class:`~wuttaweb.forms.base.Form`
instance, per the given ``kwargs``.
This is the "base" factory which merely invokes the
constructor.
"""
return forms.Form(request, **kwargs)
def make_grid(self, request, **kwargs):
"""
Make and return a new :class:`~wuttaweb.grids.base.Grid`
instance, per the given ``kwargs``.
This is the "base" factory which merely invokes the
constructor.
"""
return grids.Grid(request, **kwargs)

View file

@ -196,6 +196,11 @@
width: 50%;
}
.wutta-form-wrapper .field.is-horizontal .field-body .select,
.wutta-form-wrapper .field.is-horizontal .field-body .select select {
width: 100%;
}
</style>
</%def>

View file

@ -0,0 +1,6 @@
<div>
${field.start_mapping()}
<wutta-datepicker name="date"
value="${cstruct}" />
${field.end_mapping()}
</div>

View file

@ -0,0 +1,10 @@
<div style="display: flex; gap: 0.5rem;">
${field.start_mapping()}
<wutta-datepicker name="date"
style="flex-grow: 1;"
value="${date}" />
<wutta-timepicker name="time"
style="flex-grow: 1;"
value="${time}" />
${field.end_mapping()}
</div>

View file

@ -4,6 +4,6 @@
${str(field.schema.model_instance or '')}
</a>
<span tal:condition="not url">
${str(field.schema.model_instance or '')}
${str(getattr(field.schema, 'model_instance', None) or '')}
</span>
</tal:omit>

View file

@ -4,6 +4,14 @@
${h.form(form.action_url, method='post', enctype='multipart/form-data', **form_attrs)}
${h.csrf_token(request)}
% if form.has_global_errors():
% for msg in form.get_global_errors():
<b-notification type="is-warning" :closable="false">
${msg}
</b-notification>
% endfor
% endif
<section>
% for fieldname in form:
${form.render_vue_field(fieldname)}

View file

@ -2,6 +2,8 @@
<%def name="make_wutta_components()">
${self.make_wutta_request_mixin()}
${self.make_wutta_button_component()}
${self.make_wutta_datepicker_component()}
${self.make_wutta_timepicker_component()}
${self.make_wutta_filter_component()}
${self.make_wutta_filter_value_component()}
</%def>
@ -149,6 +151,141 @@
</script>
</%def>
<%def name="make_wutta_datepicker_component()">
<script type="text/x-template" id="wutta-datepicker-template">
<b-datepicker :name="name"
:editable="editable"
icon-pack="fas"
icon="calendar-alt"
:date-formatter="formatDate"
:value="buefyValue" />
</script>
<script>
const WuttaDatepicker = {
template: '#wutta-datepicker-template',
props: {
name: String,
value: String,
editable: {
type: Boolean,
default: true,
},
},
data() {
return {
buefyValue: this.parseDate(this.value),
}
},
methods: {
formatDate(date) {
if (date === null) {
return null
}
// just need to convert to simple ISO date format here, seems
// like there should be a more obvious way to do that?
var year = date.getFullYear()
var month = date.getMonth() + 1
var day = date.getDate()
month = month < 10 ? '0' + month : month
day = day < 10 ? '0' + day : day
return year + '-' + month + '-' + day
},
parseDate(date) {
if (!date) {
return
}
if (typeof(date) == 'string') {
// nb. this assumes classic YYYY-MM-DD (ISO) format
var parts = date.split('-')
return new Date(parts[0], parseInt(parts[1]) - 1, parts[2])
}
return date
},
},
}
Vue.component('wutta-datepicker', WuttaDatepicker)
</script>
</%def>
<%def name="make_wutta_timepicker_component()">
<script type="text/x-template" id="wutta-timepicker-template">
<b-timepicker :name="name"
editable
:value="buefyValue" />
</script>
<script>
const WuttaTimepicker = {
template: '#wutta-timepicker-template',
props: {
name: String,
value: String,
editable: {
type: Boolean,
default: true,
},
},
data() {
return {
buefyValue: this.parseTime(this.value),
}
},
methods: {
formatTime(time) {
if (time === null) {
return null
}
let h = time.getHours()
let m = time.getMinutes()
let s = time.getSeconds()
h = h < 10 ? '0' + h : h
m = m < 10 ? '0' + m : m
s = s < 10 ? '0' + s : s
return h + ':' + m + ':' + s
},
parseTime(time) {
if (time.getHours) {
return time
}
let found, hours, minutes
found = time.match(/^(\d\d):(\d\d):\d\d$/)
if (found) {
hours = parseInt(found[1])
minutes = parseInt(found[2])
return new Date(null, null, null, hours, minutes)
}
found = time.match(/^\s*(\d\d?):(\d\d)\s*([AaPp][Mm])\s*$/)
if (found) {
hours = parseInt(found[1])
minutes = parseInt(found[2])
const ampm = found[3].toUpperCase()
if (ampm == 'AM') {
if (hours == 12) {
hours = 0
}
} else { // PM
if (hours < 12) {
hours += 12
}
}
return new Date(null, null, null, hours, minutes)
}
},
},
}
Vue.component('wutta-timepicker', WuttaTimepicker)
</script>
</%def>
<%def name="make_wutta_filter_component()">
<script type="text/x-template" id="wutta-filter-template">
<div v-show="filter.visible"

View file

@ -30,7 +30,7 @@ from pyramid import httpexceptions
from pyramid.renderers import render_to_response
from pyramid.response import FileResponse
from wuttaweb import forms, grids
from wuttaweb import grids
class View:
@ -75,7 +75,8 @@ class View:
This is the "base" factory which merely invokes the
constructor.
"""
return forms.Form(self.request, **kwargs)
web = self.app.get_web_handler()
return web.make_form(self.request, **kwargs)
def make_grid(self, **kwargs):
"""
@ -85,7 +86,8 @@ class View:
This is the "base" factory which merely invokes the
constructor.
"""
return grids.Grid(self.request, **kwargs)
web = self.app.get_web_handler()
return web.make_grid(self.request, **kwargs)
def make_grid_action(self, key, **kwargs):
"""

View file

@ -2000,7 +2000,7 @@ class MasterView(View):
Default logic returns the value from ``str(instance)``;
subclass may override if needed.
"""
return str(instance)
return str(instance) or "(no title)"
def get_action_url(self, action, obj, **kwargs):
"""

View file

@ -34,7 +34,6 @@ from sqlalchemy import orm
from wuttjamaican.db.model import Upgrade
from wuttaweb.views import MasterView
from wuttaweb.forms import widgets
from wuttaweb.forms.schema import UserRef, WuttaEnum, FileDownload
from wuttaweb.progress import get_progress_session
@ -147,7 +146,7 @@ class UpgradeView(MasterView):
f.set_node('created_by', UserRef(self.request))
# notes
f.set_widget('notes', widgets.NotesWidget())
f.set_widget('notes', 'notes')
# status
if self.creating:

View file

@ -142,6 +142,26 @@ class TestForm(TestCase):
self.assertIs(form.widgets['foo'], new_widget)
self.assertIs(schema['foo'].widget, new_widget)
# can also just specify widget pseudo-type (invalid)
self.assertNotIn('bar', form.widgets)
self.assertRaises(ValueError, form.set_widget, 'bar', 'ldjfadjfadj')
# can also just specify widget pseudo-type (valid)
self.assertNotIn('bar', form.widgets)
form.set_widget('bar', 'notes')
self.assertIsInstance(form.widgets['bar'], widgets.NotesWidget)
def test_make_widget(self):
form = self.make_form(fields=['foo', 'bar'])
# notes
widget = form.make_widget('notes')
self.assertIsInstance(widget, widgets.NotesWidget)
# invalid
widget = form.make_widget('fdajvdafjjf')
self.assertIsNone(widget)
def test_set_grid(self):
form = self.make_form(fields=['foo', 'bar'])
self.assertNotIn('foo', form.widgets)
@ -544,6 +564,34 @@ class TestForm(TestCase):
data = form.get_vue_model_data()
self.assertEqual(list(data.values()), ['one', 'two', True])
def test_has_global_errors(self):
def fail(node, value):
node.raise_invalid("things are bad!")
schema = self.make_schema()
schema.validator = fail
form = self.make_form(schema=schema)
self.assertFalse(form.has_global_errors())
self.request.method = 'POST'
self.request.POST = {'foo': 'one', 'bar': 'two'}
self.assertFalse(form.validate())
self.assertTrue(form.has_global_errors())
def test_get_global_errors(self):
def fail(node, value):
node.raise_invalid("things are bad!")
schema = self.make_schema()
schema.validator = fail
form = self.make_form(schema=schema)
self.assertEqual(form.get_global_errors(), [])
self.request.method = 'POST'
self.request.POST = {'foo': 'one', 'bar': 'two'}
self.assertFalse(form.validate())
self.assertTrue(form.get_global_errors(), ["things are bad!"])
def test_get_field_errors(self):
schema = self.make_schema()

View file

@ -1,5 +1,7 @@
# -*- coding: utf-8; -*-
import datetime
from unittest import TestCase
from unittest.mock import patch
import colander
@ -13,6 +15,22 @@ from wuttaweb.forms import widgets
from tests.util import DataTestCase, WebTestCase
class TestWutaDateTime(TestCase):
def test_deserialize(self):
typ = mod.WuttaDateTime()
node = colander.SchemaNode(typ)
result = typ.deserialize(node, colander.null)
self.assertIs(result, colander.null)
result = typ.deserialize(node, '2024-12-11T10:33 PM')
self.assertIsInstance(result, datetime.datetime)
self.assertEqual(result, datetime.datetime(2024, 12, 11, 22, 33))
self.assertRaises(colander.Invalid, typ.deserialize, node, 'bogus')
class TestObjectNode(DataTestCase):
def setUp(self):
@ -102,6 +120,11 @@ class TestObjectRef(DataTestCase):
value = typ.serialize(node, person)
self.assertEqual(value, person.uuid.hex)
# null w/ empty option
typ = mod.ObjectRef(self.request, empty_option=('bad', 'BAD'))
value = typ.serialize(node, colander.null)
self.assertEqual(value, 'bad')
def test_deserialize(self):
model = self.app.model
node = colander.SchemaNode(colander.String())

View file

@ -61,6 +61,7 @@ class TestObjectRefWidget(WebTestCase):
self.session.add(person)
self.session.commit()
# standard
node = colander.SchemaNode(PersonRef(self.request, session=self.session))
widget = self.make_widget()
field = self.make_field(node)
@ -68,6 +69,15 @@ class TestObjectRefWidget(WebTestCase):
self.assertIn('cstruct', values)
self.assertNotIn('url', values)
# readonly w/ empty option
node = colander.SchemaNode(PersonRef(self.request, session=self.session,
empty_option=('_empty_', '(empty)')))
widget = self.make_widget(readonly=True, url=lambda obj: '/foo')
field = self.make_field(node)
values = widget.get_template_values(field, '_empty_', {})
self.assertIn('cstruct', values)
self.assertNotIn('url', values)
class TestFileDownloadWidget(WebTestCase):

View file

@ -1,6 +1,8 @@
# -*- coding: utf-8; -*-
from wuttaweb import handler as mod, static
from wuttaweb.forms import Form
from wuttaweb.grids import Grid
from wuttaweb.menus import MenuHandler
from tests.util import WebTestCase
@ -62,3 +64,13 @@ class TestWebHandler(WebTestCase):
handler = self.make_handler()
menus = handler.get_menu_handler()
self.assertIsInstance(menus, MenuHandler)
def test_make_form(self):
handler = self.make_handler()
form = handler.make_form(self.request)
self.assertIsInstance(form, Form)
def test_make_grid(self):
handler = self.make_handler()
grid = handler.make_grid(self.request)
self.assertIsInstance(grid, Grid)