diff --git a/docs/api/wuttaweb/forms.base.rst b/docs/api/wuttaweb/forms.base.rst
new file mode 100644
index 0000000..8569309
--- /dev/null
+++ b/docs/api/wuttaweb/forms.base.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.forms.base``
+=======================
+
+.. automodule:: wuttaweb.forms.base
+ :members:
diff --git a/docs/api/wuttaweb/forms.rst b/docs/api/wuttaweb/forms.rst
new file mode 100644
index 0000000..1d83240
--- /dev/null
+++ b/docs/api/wuttaweb/forms.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.forms``
+==================
+
+.. automodule:: wuttaweb.forms
+ :members:
diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst
index 1e8ab57..6b305cf 100644
--- a/docs/api/wuttaweb/index.rst
+++ b/docs/api/wuttaweb/index.rst
@@ -9,6 +9,8 @@
app
db
+ forms
+ forms.base
handler
helpers
menus
diff --git a/docs/conf.py b/docs/conf.py
index 3955a96..3d568ef 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -20,12 +20,15 @@ extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.intersphinx',
'sphinx.ext.viewcode',
+ 'sphinx.ext.todo',
]
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
intersphinx_mapping = {
+ 'colander': ('https://docs.pylonsproject.org/projects/colander/en/latest/', None),
+ 'deform': ('https://docs.pylonsproject.org/projects/deform/en/latest/', None),
'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None),
'python': ('https://docs.python.org/3/', None),
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
diff --git a/pyproject.toml b/pyproject.toml
index baa0fed..ce72e00 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,6 +32,7 @@ requires-python = ">= 3.8"
dependencies = [
"pyramid>=2",
"pyramid_beaker",
+ "pyramid_deform",
"pyramid_mako",
"waitress",
"WebHelpers2",
diff --git a/src/wuttaweb/app.py b/src/wuttaweb/app.py
index 506bacc..a35d00d 100644
--- a/src/wuttaweb/app.py
+++ b/src/wuttaweb/app.py
@@ -110,9 +110,13 @@ def make_pyramid_config(settings):
The config is initialized with certain features deemed useful for
all apps.
"""
+ settings.setdefault('pyramid_deform.template_search_path',
+ 'wuttaweb:templates/deform')
+
pyramid_config = Configurator(settings=settings)
pyramid_config.include('pyramid_beaker')
+ pyramid_config.include('pyramid_deform')
pyramid_config.include('pyramid_mako')
return pyramid_config
diff --git a/src/wuttaweb/forms/__init__.py b/src/wuttaweb/forms/__init__.py
new file mode 100644
index 0000000..35102be
--- /dev/null
+++ b/src/wuttaweb/forms/__init__.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# wuttaweb -- Web App for Wutta Framework
+# Copyright © 2024 Lance Edgar
+#
+# This file is part of Wutta Framework.
+#
+# Wutta Framework is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Wutta Framework is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Wutta Framework. If not, see .
+#
+################################################################################
+"""
+Forms Library
+
+The ``wuttaweb.forms`` namespace contains the following:
+
+* :class:`~wuttaweb.forms.base.Form`
+"""
+
+from .base import Form
diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py
new file mode 100644
index 0000000..b8c4a40
--- /dev/null
+++ b/src/wuttaweb/forms/base.py
@@ -0,0 +1,421 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+# wuttaweb -- Web App for Wutta Framework
+# Copyright © 2024 Lance Edgar
+#
+# This file is part of Wutta Framework.
+#
+# Wutta Framework is free software: you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option) any
+# later version.
+#
+# Wutta Framework is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# Wutta Framework. If not, see .
+#
+################################################################################
+"""
+Base form classes
+"""
+
+import json
+import logging
+
+import colander
+import deform
+from pyramid.renderers import render
+from webhelpers2.html import HTML
+
+from wuttaweb.util import get_form_data
+
+
+log = logging.getLogger(__name__)
+
+
+class FieldList(list):
+ """
+ Convenience wrapper for a form's field list. This is a subclass
+ of :class:`python:list`.
+
+ You normally would not need to instantiate this yourself, but it
+ is used under the hood for e.g. :attr:`Form.fields`.
+ """
+
+ def insert_before(self, field, newfield):
+ """
+ Insert a new field, before an existing field.
+
+ :param field: String name for the existing field.
+
+ :param newfield: String name for the new field, to be inserted
+ just before the existing ``field``.
+ """
+ if field in self:
+ i = self.index(field)
+ self.insert(i, newfield)
+ else:
+ log.warning("field '%s' not found, will append new field: %s",
+ field, newfield)
+ self.append(newfield)
+
+ def insert_after(self, field, newfield):
+ """
+ Insert a new field, after an existing field.
+
+ :param field: String name for the existing field.
+
+ :param newfield: String name for the new field, to be inserted
+ just after the existing ``field``.
+ """
+ if field in self:
+ i = self.index(field)
+ self.insert(i + 1, newfield)
+ else:
+ log.warning("field '%s' not found, will append new field: %s",
+ field, newfield)
+ self.append(newfield)
+
+
+class Form:
+ """
+ Base class for all forms.
+
+ :param request: Reference to current :term:`request` object.
+
+ :param fields: List of field names for the form. This is
+ optional; if not specified an attempt will be made to deduce
+ the list automatically. See also :attr:`fields`.
+
+ :param schema: Colander-based schema object for the form. This is
+ optional; if not specified an attempt will be made to construct
+ one automatically. See also :meth:`get_schema()`.
+
+ :param labels: Optional dict of default field labels.
+
+ .. note::
+
+ Some parameters are not explicitly described above. However
+ their corresponding attributes are described below.
+
+ Form instances contain the following attributes:
+
+ .. attribute:: fields
+
+ :class:`FieldList` instance containing string field names for
+ the form. By default, fields will appear in the same order as
+ they are in this list.
+
+ .. attribute:: request
+
+ Reference to current :term:`request` object.
+
+ .. attribute:: action_url
+
+ String URL to which the form should be submitted, if applicable.
+
+ .. attribute:: vue_tagname
+
+ String name for Vue component tag. By default this is
+ ``'wutta-form'``. See also :meth:`render_vue_tag()`.
+
+ .. attribute:: align_buttons_right
+
+ Flag indicating whether the buttons (submit, cancel etc.)
+ should be aligned to the right of the area below the form. If
+ not set, the buttons are left-aligned.
+
+ .. attribute:: auto_disable_submit
+
+ Flag indicating whether the submit button should be
+ auto-disabled, whenever the form is submitted.
+
+ .. attribute:: button_label_submit
+
+ String label for the form submit button. Default is ``"Save"``.
+
+ .. attribute:: button_icon_submit
+
+ String icon name for the form submit button. Default is ``'save'``.
+
+ .. attribute:: show_button_reset
+
+ Flag indicating whether a Reset button should be shown.
+
+ .. attribute:: validated
+
+ If the :meth:`validate()` method was called, and it succeeded,
+ this will be set to the validated data dict.
+
+ Note that in all other cases, this attribute may not exist.
+ """
+
+ def __init__(
+ self,
+ request,
+ fields=None,
+ schema=None,
+ labels={},
+ action_url=None,
+ vue_tagname='wutta-form',
+ align_buttons_right=False,
+ auto_disable_submit=True,
+ button_label_submit="Save",
+ button_icon_submit='save',
+ show_button_reset=False,
+ ):
+ self.request = request
+ self.schema = schema
+ self.labels = labels or {}
+ self.action_url = action_url
+ self.vue_tagname = vue_tagname
+ self.align_buttons_right = align_buttons_right
+ self.auto_disable_submit = auto_disable_submit
+ self.button_label_submit = button_label_submit
+ self.button_icon_submit = button_icon_submit
+ self.show_button_reset = show_button_reset
+
+ self.config = self.request.wutta_config
+ self.app = self.config.get_app()
+
+ if fields is not None:
+ self.set_fields(fields)
+ elif self.schema:
+ self.set_fields([f.name for f in self.schema])
+ else:
+ self.fields = None
+
+ def __contains__(self, name):
+ """
+ Custom logic for the ``in`` operator, to allow easily checking
+ if the form contains a given field::
+
+ myform = Form()
+ if 'somefield' in myform:
+ print("my form has some field")
+ """
+ return bool(self.fields and name in self.fields)
+
+ def __iter__(self):
+ """
+ Custom logic to allow iterating over form field names::
+
+ myform = Form(fields=['foo', 'bar'])
+ for fieldname in myform:
+ print(fieldname)
+ """
+ return iter(self.fields)
+
+ @property
+ def vue_component(self):
+ """
+ String name for the Vue component, e.g. ``'WuttaForm'``.
+
+ This is a generated value based on :attr:`vue_tagname`.
+ """
+ words = self.vue_tagname.split('-')
+ return ''.join([word.capitalize() for word in words])
+
+ def set_fields(self, fields):
+ """
+ Explicitly set the list of form fields.
+
+ This will overwrite :attr:`fields` with a new
+ :class:`FieldList` instance.
+
+ :param fields: List of string field names.
+ """
+ self.fields = FieldList(fields)
+
+ def set_label(self, key, label):
+ """
+ Set the label for given field name.
+
+ See also :meth:`get_label()`.
+ """
+ self.labels[key] = label
+
+ # update schema if necessary
+ if self.schema and key in self.schema:
+ self.schema[key].title = label
+
+ def get_label(self, key):
+ """
+ Get the label for given field name.
+
+ Note that this will always return a string, auto-generating
+ the label if needed.
+
+ See also :meth:`set_label()`.
+ """
+ return self.labels.get(key, self.app.make_title(key))
+
+ def get_schema(self):
+ """
+ Return the :class:`colander:colander.Schema` object for the
+ form, generating it automatically if necessary.
+ """
+ if not self.schema:
+ raise NotImplementedError
+
+ return self.schema
+
+ def get_deform(self):
+ """
+ Return the :class:`deform:deform.Form` instance for the form,
+ generating it automatically if necessary.
+ """
+ if not hasattr(self, 'deform_form'):
+ schema = self.get_schema()
+ form = deform.Form(schema)
+ self.deform_form = form
+
+ return self.deform_form
+
+ def render_vue_tag(self, **kwargs):
+ """
+ Render the Vue component tag for the form.
+
+ By default this simply returns:
+
+ .. code-block:: html
+
+
+
+ The actual output will depend on various form attributes, in
+ particular :attr:`vue_tagname`.
+ """
+ return HTML.tag(self.vue_tagname, **kwargs)
+
+ def render_vue_template(
+ self,
+ template='/forms/vue_template.mako',
+ **context):
+ """
+ Render the Vue template block for the form.
+
+ This returns something like:
+
+ .. code-block:: none
+
+
+
+ .. todo::
+
+ Why can't Sphinx render the above code block as 'html' ?
+
+ It acts like it can't handle a ``
+%def>
+
+
+${parent.body()}
diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako
new file mode 100644
index 0000000..0151632
--- /dev/null
+++ b/src/wuttaweb/templates/forms/vue_template.mako
@@ -0,0 +1,58 @@
+## -*- coding: utf-8; -*-
+
+
+
+
diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py
index 3906c0b..e7bfea3 100644
--- a/src/wuttaweb/views/base.py
+++ b/src/wuttaweb/views/base.py
@@ -24,6 +24,10 @@
Base Logic for Views
"""
+from pyramid import httpexceptions
+
+from wuttaweb import forms
+
class View:
"""
@@ -35,8 +39,7 @@ class View:
.. attribute:: request
- Reference to the current
- :class:`pyramid:pyramid.request.Request` object.
+ Reference to the current :term:`request` object.
.. attribute:: app
@@ -51,3 +54,30 @@ class View:
self.request = request
self.config = self.request.wutta_config
self.app = self.config.get_app()
+
+ def make_form(self, **kwargs):
+ """
+ Make and return a new :class:`~wuttaweb.forms.base.Form`
+ instance, per the given ``kwargs``.
+
+ This is the "default" form factory which merely invokes
+ the constructor.
+ """
+ return forms.Form(self.request, **kwargs)
+
+ def redirect(self, url, **kwargs):
+ """
+ Convenience method to return a HTTP 302 response.
+
+ Note that this technically returns an "exception" - so in
+ your code, you can either return that error, or raise it::
+
+ return self.redirect('/')
+ # ..or
+ raise self.redirect('/')
+
+ Which you should do will depend on context, but raising the
+ error is always "safe" since Pyramid will handle that
+ correctly no matter what.
+ """
+ return httpexceptions.HTTPFound(location=url, **kwargs)
diff --git a/tests/forms/__init__.py b/tests/forms/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py
new file mode 100644
index 0000000..96a0805
--- /dev/null
+++ b/tests/forms/test_base.py
@@ -0,0 +1,241 @@
+# -*- coding: utf-8; -*-
+
+from unittest import TestCase
+
+import colander
+import deform
+from pyramid import testing
+
+from wuttjamaican.conf import WuttaConfig
+from wuttaweb.forms import base
+from wuttaweb import helpers
+
+
+class TestFieldList(TestCase):
+
+ def test_insert_before(self):
+ fields = base.FieldList(['f1', 'f2'])
+ self.assertEqual(fields, ['f1', 'f2'])
+
+ # typical
+ fields.insert_before('f1', 'XXX')
+ self.assertEqual(fields, ['XXX', 'f1', 'f2'])
+ fields.insert_before('f2', 'YYY')
+ self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2'])
+
+ # appends new field if reference field is invalid
+ fields.insert_before('f3', 'ZZZ')
+ self.assertEqual(fields, ['XXX', 'f1', 'YYY', 'f2', 'ZZZ'])
+
+ def test_insert_after(self):
+ fields = base.FieldList(['f1', 'f2'])
+ self.assertEqual(fields, ['f1', 'f2'])
+
+ # typical
+ fields.insert_after('f1', 'XXX')
+ self.assertEqual(fields, ['f1', 'XXX', 'f2'])
+ fields.insert_after('XXX', 'YYY')
+ self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2'])
+
+ # appends new field if reference field is invalid
+ fields.insert_after('f3', 'ZZZ')
+ self.assertEqual(fields, ['f1', 'XXX', 'YYY', 'f2', 'ZZZ'])
+
+
+class TestForm(TestCase):
+
+ def setUp(self):
+ self.config = WuttaConfig()
+ self.request = testing.DummyRequest(wutta_config=self.config)
+
+ self.pyramid_config = testing.setUp(request=self.request, settings={
+ 'mako.directories': ['wuttaweb:templates'],
+ 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
+ })
+
+ def tearDown(self):
+ testing.tearDown()
+
+ def make_form(self, request=None, **kwargs):
+ return base.Form(request or self.request, **kwargs)
+
+ def make_schema(self):
+ schema = colander.Schema(children=[
+ colander.SchemaNode(colander.String(),
+ name='foo'),
+ colander.SchemaNode(colander.String(),
+ name='bar'),
+ ])
+ return schema
+
+ def test_init_with_none(self):
+ form = self.make_form()
+ self.assertIsNone(form.fields)
+
+ def test_init_with_fields(self):
+ form = self.make_form(fields=['foo', 'bar'])
+ self.assertEqual(form.fields, ['foo', 'bar'])
+
+ def test_init_with_schema(self):
+ schema = self.make_schema()
+ form = self.make_form(schema=schema)
+ self.assertEqual(form.fields, ['foo', 'bar'])
+
+ def test_vue_tagname(self):
+ form = self.make_form()
+ self.assertEqual(form.vue_tagname, 'wutta-form')
+
+ def test_vue_component(self):
+ form = self.make_form()
+ self.assertEqual(form.vue_component, 'WuttaForm')
+
+ def test_contains(self):
+ form = self.make_form(fields=['foo', 'bar'])
+ self.assertIn('foo', form)
+ self.assertNotIn('baz', form)
+
+ def test_iter(self):
+ form = self.make_form(fields=['foo', 'bar'])
+
+ fields = list(iter(form))
+ self.assertEqual(fields, ['foo', 'bar'])
+
+ fields = []
+ for field in form:
+ fields.append(field)
+ self.assertEqual(fields, ['foo', 'bar'])
+
+ def test_set_fields(self):
+ form = self.make_form(fields=['foo', 'bar'])
+ self.assertEqual(form.fields, ['foo', 'bar'])
+ form.set_fields(['baz'])
+ self.assertEqual(form.fields, ['baz'])
+
+ def test_get_schema(self):
+ form = self.make_form()
+ self.assertIsNone(form.schema)
+
+ # provided schema is returned
+ schema = self.make_schema()
+ form = self.make_form(schema=schema)
+ self.assertIs(form.schema, schema)
+ self.assertIs(form.get_schema(), schema)
+
+ # auto-generating schema not yet supported
+ form = self.make_form(fields=['foo', 'bar'])
+ self.assertIsNone(form.schema)
+ self.assertRaises(NotImplementedError, form.get_schema)
+
+ def test_get_deform(self):
+ schema = self.make_schema()
+ form = self.make_form(schema=schema)
+ self.assertFalse(hasattr(form, 'deform_form'))
+ dform = form.get_deform()
+ self.assertIsInstance(dform, deform.Form)
+ self.assertIs(form.deform_form, dform)
+
+ def test_get_label(self):
+ form = self.make_form(fields=['foo', 'bar'])
+ self.assertEqual(form.get_label('foo'), "Foo")
+ form.set_label('foo', "Baz")
+ self.assertEqual(form.get_label('foo'), "Baz")
+
+ def test_set_label(self):
+ form = self.make_form(fields=['foo', 'bar'])
+ self.assertEqual(form.get_label('foo'), "Foo")
+ form.set_label('foo', "Baz")
+ self.assertEqual(form.get_label('foo'), "Baz")
+
+ # schema should be updated when setting label
+ schema = self.make_schema()
+ form = self.make_form(schema=schema)
+ form.set_label('foo', "Woohoo")
+ self.assertEqual(form.get_label('foo'), "Woohoo")
+ self.assertEqual(schema['foo'].title, "Woohoo")
+
+ def test_render_vue_tag(self):
+ schema = self.make_schema()
+ form = self.make_form(schema=schema)
+ html = form.render_vue_tag()
+ self.assertEqual(html, '')
+
+ def test_render_vue_template(self):
+ self.pyramid_config.include('pyramid_mako')
+ self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
+ 'pyramid.events.BeforeRender')
+
+ # form button is disabled on @submit by default
+ schema = self.make_schema()
+ form = self.make_form(schema=schema)
+ html = form.render_vue_template()
+ self.assertIn('