feat: add initial/basic forms support
This commit is contained in:
parent
0604651be5
commit
95d3623a5e
16 changed files with 858 additions and 10 deletions
|
@ -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
|
||||
|
|
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
|
||||
"""
|
||||
|
||||
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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue