From 74e2a4f0e278117229178722a050d1cc3b2a5cb4 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Dec 2024 09:48:50 -0600 Subject: [PATCH 1/7] fix: correct "empty option" behavior for `ObjectRef` schema type --- src/wuttaweb/forms/schema.py | 5 +++++ src/wuttaweb/forms/widgets.py | 2 +- src/wuttaweb/templates/deform/readonly/objectref.pt | 2 +- tests/forms/test_schema.py | 5 +++++ tests/forms/test_widgets.py | 10 ++++++++++ 5 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index 6347b61..b618187 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -207,6 +207,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 diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py index 2d91eb9..b4ed6a3 100644 --- a/src/wuttaweb/forms/widgets.py +++ b/src/wuttaweb/forms/widgets.py @@ -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 diff --git a/src/wuttaweb/templates/deform/readonly/objectref.pt b/src/wuttaweb/templates/deform/readonly/objectref.pt index 3ab9e0e..70c1040 100644 --- a/src/wuttaweb/templates/deform/readonly/objectref.pt +++ b/src/wuttaweb/templates/deform/readonly/objectref.pt @@ -4,6 +4,6 @@ ${str(field.schema.model_instance or '')} - ${str(field.schema.model_instance or '')} + ${str(getattr(field.schema, 'model_instance', None) or '')} diff --git a/tests/forms/test_schema.py b/tests/forms/test_schema.py index e7f9f36..97b9719 100644 --- a/tests/forms/test_schema.py +++ b/tests/forms/test_schema.py @@ -102,6 +102,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()) diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index f479e5a..0ff5696 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -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): From 448dc9fc7939e97da5b901d179b1f23e7ea27647 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Dec 2024 22:06:33 -0600 Subject: [PATCH 2/7] fix: add `make_form()` and `make_grid()` methods on web handler to allow override --- src/wuttaweb/handler.py | 22 +++++++++++++++++++++- src/wuttaweb/views/base.py | 8 +++++--- tests/test_handler.py | 12 ++++++++++++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/wuttaweb/handler.py b/src/wuttaweb/handler.py index f5b4d71..1ac0b78 100644 --- a/src/wuttaweb/handler.py +++ b/src/wuttaweb/handler.py @@ -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) diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py index 5121f3c..c5bb0dc 100644 --- a/src/wuttaweb/views/base.py +++ b/src/wuttaweb/views/base.py @@ -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): """ diff --git a/tests/test_handler.py b/tests/test_handler.py index 9c4037f..effb413 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -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) From 6e4f390f3ffd97250787b32df73b3428a580555f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Dec 2024 22:28:32 -0600 Subject: [PATCH 3/7] fix: display "global" errors at top of form, if present this probably could use more work, good enough for now --- src/wuttaweb/forms/base.py | 26 +++++++++++++++++ .../templates/forms/vue_template.mako | 8 ++++++ tests/forms/test_base.py | 28 +++++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index 8f5cafc..83cd069 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -1111,6 +1111,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. diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako index 5a4af70..d913054 100644 --- a/src/wuttaweb/templates/forms/vue_template.mako +++ b/src/wuttaweb/templates/forms/vue_template.mako @@ -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(): + + ${msg} + + % endfor + % endif +
% for fieldname in form: ${form.render_vue_field(fieldname)} diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index ef8e4b6..54bf4da 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -544,6 +544,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() From 7fc5d84abde16b8ca79d9737aab6e7689f65f4e5 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Dec 2024 22:29:20 -0600 Subject: [PATCH 4/7] fix: add fallback instance title otherwise the hero bar can be hidden altogether --- src/wuttaweb/views/master.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 6e70c4d..4ce9d2d 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -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): """ From fce1bf9de4a7191017e22fdb2d536ed6545d5a98 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Dec 2024 22:30:03 -0600 Subject: [PATCH 5/7] fix: make dropdown widgets as wide as other text fields in main form --- src/wuttaweb/templates/base.mako | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako index 8da535a..695bfb8 100644 --- a/src/wuttaweb/templates/base.mako +++ b/src/wuttaweb/templates/base.mako @@ -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%; + } + From bf8397ba23c0124cf9ecf09c20e390be536c4f2c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 11 Dec 2024 22:38:51 -0600 Subject: [PATCH 6/7] fix: add support for date, datetime form fields using buefy-based picker widgets etc. --- docs/conf.py | 1 + src/wuttaweb/forms/schema.py | 29 ++++ src/wuttaweb/templates/deform/dateinput.pt | 6 + .../templates/deform/datetimeinput.pt | 10 ++ src/wuttaweb/templates/wutta-components.mako | 137 ++++++++++++++++++ tests/forms/test_schema.py | 18 +++ 6 files changed, 201 insertions(+) create mode 100644 src/wuttaweb/templates/deform/dateinput.pt create mode 100644 src/wuttaweb/templates/deform/datetimeinput.pt diff --git a/docs/conf.py b/docs/conf.py index cf15be8..8bf7c8e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -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), diff --git a/src/wuttaweb/forms/schema.py b/src/wuttaweb/forms/schema.py index b618187..275d42c 100644 --- a/src/wuttaweb/forms/schema.py +++ b/src/wuttaweb/forms/schema.py @@ -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 @@ -502,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} diff --git a/src/wuttaweb/templates/deform/dateinput.pt b/src/wuttaweb/templates/deform/dateinput.pt new file mode 100644 index 0000000..b0e1285 --- /dev/null +++ b/src/wuttaweb/templates/deform/dateinput.pt @@ -0,0 +1,6 @@ +
+ ${field.start_mapping()} + + ${field.end_mapping()} +
diff --git a/src/wuttaweb/templates/deform/datetimeinput.pt b/src/wuttaweb/templates/deform/datetimeinput.pt new file mode 100644 index 0000000..b617929 --- /dev/null +++ b/src/wuttaweb/templates/deform/datetimeinput.pt @@ -0,0 +1,10 @@ +
+ ${field.start_mapping()} + + + ${field.end_mapping()} +
diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako index b52992e..6030840 100644 --- a/src/wuttaweb/templates/wutta-components.mako +++ b/src/wuttaweb/templates/wutta-components.mako @@ -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()} @@ -149,6 +151,141 @@ +<%def name="make_wutta_datepicker_component()"> + + + + +<%def name="make_wutta_timepicker_component()"> + + + + <%def name="make_wutta_filter_component()">