feat: add initial/basic forms support
This commit is contained in:
parent
0604651be5
commit
95d3623a5e
6
docs/api/wuttaweb/forms.base.rst
Normal file
6
docs/api/wuttaweb/forms.base.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttaweb.forms.base``
|
||||||
|
=======================
|
||||||
|
|
||||||
|
.. automodule:: wuttaweb.forms.base
|
||||||
|
:members:
|
6
docs/api/wuttaweb/forms.rst
Normal file
6
docs/api/wuttaweb/forms.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttaweb.forms``
|
||||||
|
==================
|
||||||
|
|
||||||
|
.. automodule:: wuttaweb.forms
|
||||||
|
:members:
|
|
@ -9,6 +9,8 @@
|
||||||
|
|
||||||
app
|
app
|
||||||
db
|
db
|
||||||
|
forms
|
||||||
|
forms.base
|
||||||
handler
|
handler
|
||||||
helpers
|
helpers
|
||||||
menus
|
menus
|
||||||
|
|
|
@ -20,12 +20,15 @@ extensions = [
|
||||||
'sphinx.ext.autodoc',
|
'sphinx.ext.autodoc',
|
||||||
'sphinx.ext.intersphinx',
|
'sphinx.ext.intersphinx',
|
||||||
'sphinx.ext.viewcode',
|
'sphinx.ext.viewcode',
|
||||||
|
'sphinx.ext.todo',
|
||||||
]
|
]
|
||||||
|
|
||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||||
|
|
||||||
intersphinx_mapping = {
|
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),
|
'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None),
|
||||||
'python': ('https://docs.python.org/3/', None),
|
'python': ('https://docs.python.org/3/', None),
|
||||||
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
|
'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
|
||||||
|
|
|
@ -32,6 +32,7 @@ requires-python = ">= 3.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pyramid>=2",
|
"pyramid>=2",
|
||||||
"pyramid_beaker",
|
"pyramid_beaker",
|
||||||
|
"pyramid_deform",
|
||||||
"pyramid_mako",
|
"pyramid_mako",
|
||||||
"waitress",
|
"waitress",
|
||||||
"WebHelpers2",
|
"WebHelpers2",
|
||||||
|
|
|
@ -110,9 +110,13 @@ def make_pyramid_config(settings):
|
||||||
The config is initialized with certain features deemed useful for
|
The config is initialized with certain features deemed useful for
|
||||||
all apps.
|
all apps.
|
||||||
"""
|
"""
|
||||||
|
settings.setdefault('pyramid_deform.template_search_path',
|
||||||
|
'wuttaweb:templates/deform')
|
||||||
|
|
||||||
pyramid_config = Configurator(settings=settings)
|
pyramid_config = Configurator(settings=settings)
|
||||||
|
|
||||||
pyramid_config.include('pyramid_beaker')
|
pyramid_config.include('pyramid_beaker')
|
||||||
|
pyramid_config.include('pyramid_deform')
|
||||||
pyramid_config.include('pyramid_mako')
|
pyramid_config.include('pyramid_mako')
|
||||||
|
|
||||||
return pyramid_config
|
return pyramid_config
|
||||||
|
|
31
src/wuttaweb/forms/__init__.py
Normal file
31
src/wuttaweb/forms/__init__.py
Normal 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
421
src/wuttaweb/forms/base.py
Normal 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
|
8
src/wuttaweb/templates/deform/password.pt
Normal file
8
src/wuttaweb/templates/deform/password.pt
Normal 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>
|
7
src/wuttaweb/templates/deform/textinput.pt
Normal file
7
src/wuttaweb/templates/deform/textinput.pt
Normal 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>
|
18
src/wuttaweb/templates/form.mako
Normal file
18
src/wuttaweb/templates/form.mako
Normal 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()}
|
58
src/wuttaweb/templates/forms/vue_template.mako
Normal file
58
src/wuttaweb/templates/forms/vue_template.mako
Normal 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>
|
|
@ -24,6 +24,10 @@
|
||||||
Base Logic for Views
|
Base Logic for Views
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from pyramid import httpexceptions
|
||||||
|
|
||||||
|
from wuttaweb import forms
|
||||||
|
|
||||||
|
|
||||||
class View:
|
class View:
|
||||||
"""
|
"""
|
||||||
|
@ -35,8 +39,7 @@ class View:
|
||||||
|
|
||||||
.. attribute:: request
|
.. attribute:: request
|
||||||
|
|
||||||
Reference to the current
|
Reference to the current :term:`request` object.
|
||||||
:class:`pyramid:pyramid.request.Request` object.
|
|
||||||
|
|
||||||
.. attribute:: app
|
.. attribute:: app
|
||||||
|
|
||||||
|
@ -51,3 +54,30 @@ class View:
|
||||||
self.request = request
|
self.request = request
|
||||||
self.config = self.request.wutta_config
|
self.config = self.request.wutta_config
|
||||||
self.app = self.config.get_app()
|
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
0
tests/forms/__init__.py
Normal file
241
tests/forms/test_base.py
Normal file
241
tests/forms/test_base.py
Normal 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")
|
|
@ -3,19 +3,31 @@
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
from pyramid import testing
|
from pyramid import testing
|
||||||
|
from pyramid.httpexceptions import HTTPFound
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
from wuttjamaican.conf import WuttaConfig
|
||||||
from wuttaweb.views import base
|
from wuttaweb.views import base
|
||||||
|
from wuttaweb.forms import Form
|
||||||
|
|
||||||
|
|
||||||
class TestView(TestCase):
|
class TestView(TestCase):
|
||||||
|
|
||||||
def test_basic(self):
|
def setUp(self):
|
||||||
config = WuttaConfig()
|
self.config = WuttaConfig()
|
||||||
request = testing.DummyRequest()
|
self.app = self.config.get_app()
|
||||||
request.wutta_config = config
|
self.request = testing.DummyRequest(wutta_config=self.config)
|
||||||
|
self.view = base.View(self.request)
|
||||||
|
|
||||||
view = base.View(request)
|
def test_basic(self):
|
||||||
self.assertIs(view.request, request)
|
self.assertIs(self.view.request, self.request)
|
||||||
self.assertIs(view.config, config)
|
self.assertIs(self.view.config, self.config)
|
||||||
self.assertIs(view.app, config.get_app())
|
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, '/')
|
||||||
|
|
Loading…
Reference in a new issue