1
0
Fork 0

feat: add initial/basic forms support

This commit is contained in:
Lance Edgar 2024-08-04 20:35:41 -05:00
parent 0604651be5
commit 95d3623a5e
16 changed files with 858 additions and 10 deletions

View file

@ -0,0 +1,6 @@
``wuttaweb.forms.base``
=======================
.. automodule:: wuttaweb.forms.base
:members:

View file

@ -0,0 +1,6 @@
``wuttaweb.forms``
==================
.. automodule:: wuttaweb.forms
:members:

View file

@ -9,6 +9,8 @@
app
db
forms
forms.base
handler
helpers
menus

View file

@ -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),

View file

@ -32,6 +32,7 @@ requires-python = ">= 3.8"
dependencies = [
"pyramid>=2",
"pyramid_beaker",
"pyramid_deform",
"pyramid_mako",
"waitress",
"WebHelpers2",

View file

@ -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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Forms Library
The ``wuttaweb.forms`` namespace contains the following:
* :class:`~wuttaweb.forms.base.Form`
"""
from .base import Form

421
src/wuttaweb/forms/base.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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
<wutta-form></wutta-form>
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
<script type="text/x-template" id="wutta-form-template">
<form>
<!-- fields etc. -->
</form>
</script>
.. todo::
Why can't Sphinx render the above code block as 'html' ?
It acts like it can't handle a ``<script>`` tag at all?
Actual output will of course depend on form attributes, i.e.
:attr:`vue_tagname` and :attr:`fields` list etc.
:param template: Path to Mako template which is used to render
the output.
"""
context['form'] = self
context.setdefault('form_attrs', {})
# auto disable button on submit
if self.auto_disable_submit:
context['form_attrs']['@submit'] = 'formSubmitting = true'
output = render(template, context)
return HTML.literal(output)
def render_vue_field(self, fieldname):
"""
Render the given field completely, i.e. ``<b-field>`` wrapper
with label and containing a widget.
Actual output will depend on the field attributes etc.
"""
dform = self.get_deform()
field = dform[fieldname]
# render the field widget or whatever
html = field.serialize()
html = HTML.literal(html)
# render field label
label = self.get_label(fieldname)
# b-field attrs
attrs = {
':horizontal': 'true',
'label': label,
}
return HTML.tag('b-field', c=[html], **attrs)
def get_vue_field_value(self, field):
"""
This method returns a JSON string which will be assigned as
the initial model value for the given field. This JSON will
be written as part of the overall response, to be interpreted
on the client side.
Again, this must return a *string* such as:
* ``'null'``
* ``'{"foo": "bar"}'``
In practice this calls :meth:`jsonify_value()` to convert the
``field.cstruct`` value to string.
"""
if isinstance(field, str):
dform = self.get_deform()
field = dform[field]
return self.jsonify_value(field.cstruct)
def jsonify_value(self, value):
"""
Convert a Python value to JSON string.
See also :meth:`get_vue_field_value()`.
"""
if value is colander.null:
return 'null'
return json.dumps(value)
def validate(self):
"""
Try to validate the form.
This should work whether request data was submitted as classic
POST data, or as JSON body.
If the form data is valid, this method returns the data dict.
This data dict is also then available on the form object via
the :attr:`validated` attribute.
However if the data is not valid, ``False`` is returned, and
there will be no :attr:`validated` attribute.
:returns: Data dict, or ``False``.
"""
if hasattr(self, 'validated'):
del self.validated
if self.request.method != 'POST':
return False
dform = self.get_deform()
controls = get_form_data(self.request).items()
try:
self.validated = dform.validate(controls)
except deform.ValidationFailure:
return False
return self.validated

View file

@ -0,0 +1,8 @@
<div tal:omit-tag=""
tal:define="name name|field.name;
vmodel vmodel|'model_'+name;">
<b-input name="${name}"
v-model="${vmodel}"
type="password"
tal:attributes="attributes|field.widget.attributes|{};" />
</div>

View file

@ -0,0 +1,7 @@
<div tal:omit-tag=""
tal:define="name name|field.name;
vmodel vmodel|'model_'+name;">
<b-input name="${name}"
v-model="${vmodel}"
tal:attributes="attributes|field.widget.attributes|{};" />
</div>

View file

@ -0,0 +1,18 @@
## -*- coding: utf-8; -*-
<%inherit file="/page.mako" />
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
${form.render_vue_template()}
</%def>
<%def name="finalize_this_page_vars()">
${parent.finalize_this_page_vars()}
<script>
${form.vue_component}.data = function() { return ${form.vue_component}Data }
Vue.component('${form.vue_tagname}', ${form.vue_component})
</script>
</%def>
${parent.body()}

View file

@ -0,0 +1,58 @@
## -*- coding: utf-8; -*-
<script type="text/x-template" id="${form.vue_tagname}-template">
${h.form(form.action_url, method='post', enctype='multipart/form-data', **form_attrs)}
<section>
% for fieldname in form:
${form.render_vue_field(fieldname)}
% endfor
</section>
<div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: end; width: 100%;">
% if form.show_button_reset:
<b-button native-type="reset">
Reset
</b-button>
% endif
<b-button type="is-primary"
native-type="submit"
% if form.auto_disable_submit:
:disabled="formSubmitting"
% endif
icon-pack="fas"
icon-left="${form.button_icon_submit}">
% if form.auto_disable_submit:
{{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
% else:
${form.button_label_submit}
% endif
</b-button>
</div>
${h.end_form()}
</script>
<script>
let ${form.vue_component} = {
template: '#${form.vue_tagname}-template',
methods: {},
}
let ${form.vue_component}Data = {
## field model values
% for key in form:
model_${key}: ${form.get_vue_field_value(key)|n},
% endfor
% if form.auto_disable_submit:
formSubmitting: false,
% endif
}
</script>

View file

@ -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)

0
tests/forms/__init__.py Normal file
View file

241
tests/forms/test_base.py Normal file
View file

@ -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, '<wutta-form></wutta-form>')
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('<script type="text/x-template" id="wutta-form-template">', html)
self.assertIn('@submit', html)
# but not if form is configured otherwise
form = self.make_form(schema=schema, auto_disable_submit=False)
html = form.render_vue_template()
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
self.assertNotIn('@submit', html)
def test_render_vue_field(self):
self.pyramid_config.include('pyramid_deform')
schema = self.make_schema()
form = self.make_form(schema=schema)
html = form.render_vue_field('foo')
self.assertIn('<b-field :horizontal="true" label="Foo">', html)
self.assertIn('<b-input name="foo"', html)
def test_get_vue_field_value(self):
schema = self.make_schema()
form = self.make_form(schema=schema)
# null field value
value = form.get_vue_field_value('foo')
self.assertEqual(value, 'null')
# non-default / explicit value
# TODO: surely need a different approach to set value
dform = form.get_deform()
dform['foo'].cstruct = 'blarg'
value = form.get_vue_field_value('foo')
self.assertEqual(value, '"blarg"')
def test_jsonify_value(self):
form = self.make_form()
# null field value
value = form.jsonify_value(colander.null)
self.assertEqual(value, 'null')
value = form.jsonify_value(None)
self.assertEqual(value, 'null')
# string value
value = form.jsonify_value('blarg')
self.assertEqual(value, '"blarg"')
def test_validate(self):
schema = self.make_schema()
form = self.make_form(schema=schema)
self.assertFalse(hasattr(form, 'validated'))
# will not validate unless request is POST
self.request.POST = {'foo': 'blarg', 'bar': 'baz'}
self.request.method = 'GET'
self.assertFalse(form.validate())
self.request.method = 'POST'
data = form.validate()
self.assertEqual(data, {'foo': 'blarg', 'bar': 'baz'})
# validating a second type updates form.validated
self.request.POST = {'foo': 'BLARG', 'bar': 'BAZ'}
data = form.validate()
self.assertEqual(data, {'foo': 'BLARG', 'bar': 'BAZ'})
self.assertIs(form.validated, data)
# bad data does not validate
self.request.POST = {'foo': 42, 'bar': None}
self.assertFalse(form.validate())
dform = form.get_deform()
self.assertEqual(len(dform.error.children), 2)
self.assertEqual(dform['foo'].errormsg, "Pstruct is not a string")

View file

@ -3,19 +3,31 @@
from unittest import TestCase
from pyramid import testing
from pyramid.httpexceptions import HTTPFound
from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import base
from wuttaweb.forms import Form
class TestView(TestCase):
def test_basic(self):
config = WuttaConfig()
request = testing.DummyRequest()
request.wutta_config = config
def setUp(self):
self.config = WuttaConfig()
self.app = self.config.get_app()
self.request = testing.DummyRequest(wutta_config=self.config)
self.view = base.View(self.request)
view = base.View(request)
self.assertIs(view.request, request)
self.assertIs(view.config, config)
self.assertIs(view.app, config.get_app())
def test_basic(self):
self.assertIs(self.view.request, self.request)
self.assertIs(self.view.config, self.config)
self.assertIs(self.view.app, self.app)
def test_make_form(self):
form = self.view.make_form()
self.assertIsInstance(form, Form)
def test_redirect(self):
error = self.view.redirect('/')
self.assertIsInstance(error, HTTPFound)
self.assertEqual(error.location, '/')