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 `` + + + +${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('