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), 'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None),
'python': ('https://docs.python.org/3/', None), 'python': ('https://docs.python.org/3/', None),
'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', 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), 'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
'wutta-continuum': ('https://rattailproject.org/docs/wutta-continuum/', None), 'wutta-continuum': ('https://rattailproject.org/docs/wutta-continuum/', None),

View file

@ -457,23 +457,62 @@ class Form:
if self.schema: if self.schema:
self.schema[key] = node self.schema[key] = node
def set_widget(self, key, widget): def set_widget(self, key, widget, **kwargs):
""" """
Set/override the widget for a field. 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 key: Name of field.
:param widget: Instance of :param widget: Either a :class:`deform:deform.widget.Widget`
: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`. 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 self.widgets[key] = widget
# update schema if necessary # update schema if necessary
if self.schema and key in self.schema: if self.schema and key in self.schema:
self.schema[key].widget = widget 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): def set_grid(self, key, grid):
""" """
Establish a :term:`grid` to be displayed for a field. This Establish a :term:`grid` to be displayed for a field. This
@ -1111,6 +1150,32 @@ class Form:
return self.validated 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): def get_field_errors(self, field):
""" """
Return a list of error messages for the given field. Return a list of error messages for the given field.

View file

@ -24,15 +24,40 @@
Form schema types Form schema types
""" """
import datetime
import uuid as _uuid import uuid as _uuid
import colander import colander
import sqlalchemy as sa
from wuttaweb.db import Session from wuttaweb.db import Session
from wuttaweb.forms import widgets from wuttaweb.forms import widgets
from wuttjamaican.db.model import Person 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): class ObjectNode(colander.SchemaNode):
""" """
Custom schema node class which adds methods for compatibility with Custom schema node class which adds methods for compatibility with
@ -207,6 +232,11 @@ class ObjectRef(colander.SchemaType):
def serialize(self, node, appstruct): 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: if appstruct is colander.null:
return colander.null return colander.null
@ -497,3 +527,7 @@ class FileDownload(colander.String):
""" """ """ """
kwargs.setdefault('url', self.url) kwargs.setdefault('url', self.url)
return widgets.FileDownloadWidget(self.request, **kwargs) 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 # add url, only if rendering readonly
readonly = kw.get('readonly', self.readonly) readonly = kw.get('readonly', self.readonly)
if 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) values['url'] = self.url(field.schema.model_instance)
return values return values

View file

@ -26,7 +26,7 @@ Web Handler
from wuttjamaican.app import GenericHandler from wuttjamaican.app import GenericHandler
from wuttaweb import static from wuttaweb import static, forms, grids
class WebHandler(GenericHandler): class WebHandler(GenericHandler):
@ -122,3 +122,23 @@ class WebHandler(GenericHandler):
default='wuttaweb.menus:MenuHandler') default='wuttaweb.menus:MenuHandler')
self.menu_handler = self.app.load_object(spec)(self.config) self.menu_handler = self.app.load_object(spec)(self.config)
return self.menu_handler 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%; width: 50%;
} }
.wutta-form-wrapper .field.is-horizontal .field-body .select,
.wutta-form-wrapper .field.is-horizontal .field-body .select select {
width: 100%;
}
</style> </style>
</%def> </%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 '')} ${str(field.schema.model_instance or '')}
</a> </a>
<span tal:condition="not url"> <span tal:condition="not url">
${str(field.schema.model_instance or '')} ${str(getattr(field.schema, 'model_instance', None) or '')}
</span> </span>
</tal:omit> </tal:omit>

View file

@ -4,6 +4,14 @@
${h.form(form.action_url, method='post', enctype='multipart/form-data', **form_attrs)} ${h.form(form.action_url, method='post', enctype='multipart/form-data', **form_attrs)}
${h.csrf_token(request)} ${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> <section>
% for fieldname in form: % for fieldname in form:
${form.render_vue_field(fieldname)} ${form.render_vue_field(fieldname)}

View file

@ -2,6 +2,8 @@
<%def name="make_wutta_components()"> <%def name="make_wutta_components()">
${self.make_wutta_request_mixin()} ${self.make_wutta_request_mixin()}
${self.make_wutta_button_component()} ${self.make_wutta_button_component()}
${self.make_wutta_datepicker_component()}
${self.make_wutta_timepicker_component()}
${self.make_wutta_filter_component()} ${self.make_wutta_filter_component()}
${self.make_wutta_filter_value_component()} ${self.make_wutta_filter_value_component()}
</%def> </%def>
@ -149,6 +151,141 @@
</script> </script>
</%def> </%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()"> <%def name="make_wutta_filter_component()">
<script type="text/x-template" id="wutta-filter-template"> <script type="text/x-template" id="wutta-filter-template">
<div v-show="filter.visible" <div v-show="filter.visible"

View file

@ -30,7 +30,7 @@ from pyramid import httpexceptions
from pyramid.renderers import render_to_response from pyramid.renderers import render_to_response
from pyramid.response import FileResponse from pyramid.response import FileResponse
from wuttaweb import forms, grids from wuttaweb import grids
class View: class View:
@ -75,7 +75,8 @@ class View:
This is the "base" factory which merely invokes the This is the "base" factory which merely invokes the
constructor. 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): def make_grid(self, **kwargs):
""" """
@ -85,7 +86,8 @@ class View:
This is the "base" factory which merely invokes the This is the "base" factory which merely invokes the
constructor. 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): def make_grid_action(self, key, **kwargs):
""" """

View file

@ -2000,7 +2000,7 @@ class MasterView(View):
Default logic returns the value from ``str(instance)``; Default logic returns the value from ``str(instance)``;
subclass may override if needed. subclass may override if needed.
""" """
return str(instance) return str(instance) or "(no title)"
def get_action_url(self, action, obj, **kwargs): 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 wuttjamaican.db.model import Upgrade
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.forms import widgets
from wuttaweb.forms.schema import UserRef, WuttaEnum, FileDownload from wuttaweb.forms.schema import UserRef, WuttaEnum, FileDownload
from wuttaweb.progress import get_progress_session from wuttaweb.progress import get_progress_session
@ -147,7 +146,7 @@ class UpgradeView(MasterView):
f.set_node('created_by', UserRef(self.request)) f.set_node('created_by', UserRef(self.request))
# notes # notes
f.set_widget('notes', widgets.NotesWidget()) f.set_widget('notes', 'notes')
# status # status
if self.creating: if self.creating:

View file

@ -142,6 +142,26 @@ class TestForm(TestCase):
self.assertIs(form.widgets['foo'], new_widget) self.assertIs(form.widgets['foo'], new_widget)
self.assertIs(schema['foo'].widget, 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): def test_set_grid(self):
form = self.make_form(fields=['foo', 'bar']) form = self.make_form(fields=['foo', 'bar'])
self.assertNotIn('foo', form.widgets) self.assertNotIn('foo', form.widgets)
@ -544,6 +564,34 @@ class TestForm(TestCase):
data = form.get_vue_model_data() data = form.get_vue_model_data()
self.assertEqual(list(data.values()), ['one', 'two', True]) 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): def test_get_field_errors(self):
schema = self.make_schema() schema = self.make_schema()

View file

@ -1,5 +1,7 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
import datetime
from unittest import TestCase
from unittest.mock import patch from unittest.mock import patch
import colander import colander
@ -13,6 +15,22 @@ from wuttaweb.forms import widgets
from tests.util import DataTestCase, WebTestCase 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): class TestObjectNode(DataTestCase):
def setUp(self): def setUp(self):
@ -102,6 +120,11 @@ class TestObjectRef(DataTestCase):
value = typ.serialize(node, person) value = typ.serialize(node, person)
self.assertEqual(value, person.uuid.hex) 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): def test_deserialize(self):
model = self.app.model model = self.app.model
node = colander.SchemaNode(colander.String()) node = colander.SchemaNode(colander.String())

View file

@ -61,6 +61,7 @@ class TestObjectRefWidget(WebTestCase):
self.session.add(person) self.session.add(person)
self.session.commit() self.session.commit()
# standard
node = colander.SchemaNode(PersonRef(self.request, session=self.session)) node = colander.SchemaNode(PersonRef(self.request, session=self.session))
widget = self.make_widget() widget = self.make_widget()
field = self.make_field(node) field = self.make_field(node)
@ -68,6 +69,15 @@ class TestObjectRefWidget(WebTestCase):
self.assertIn('cstruct', values) self.assertIn('cstruct', values)
self.assertNotIn('url', 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): class TestFileDownloadWidget(WebTestCase):

View file

@ -1,6 +1,8 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from wuttaweb import handler as mod, static from wuttaweb import handler as mod, static
from wuttaweb.forms import Form
from wuttaweb.grids import Grid
from wuttaweb.menus import MenuHandler from wuttaweb.menus import MenuHandler
from tests.util import WebTestCase from tests.util import WebTestCase
@ -62,3 +64,13 @@ class TestWebHandler(WebTestCase):
handler = self.make_handler() handler = self.make_handler()
menus = handler.get_menu_handler() menus = handler.get_menu_handler()
self.assertIsInstance(menus, MenuHandler) 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)