Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add class specialisations for using WTForms with the frontend toolkit #400

Merged
merged 8 commits into from
Aug 6, 2018
75 changes: 75 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,81 @@

Records breaking changes from major version bumps

## 42.0.0

PR [#400](https://github.com/alphagov/digitalmarketplace-utils/pull/400)

This bump introduces new classes for using WTForms with the frontend toolkit in a way that is unambiguous.

The fields in `dmutils.forms.fields` module have been rewritten to use the new `DMFieldMixin`. Fields which use the mixin can be identified by the 'DM' prefix.
This update also includes 'DM' widgets which are able to use our template macros from `digitalmarketplace-frontend-toolkit`. If the appropriate Jinja2 templates
are loaded into the app, calling the class will render the form fully without further code.

Apps which use this version of `dmutils` should aim to use the new classes everywhere where WTForms is used so that our code is consistent across the board.

Old code:
```
# app/main/forms/form.py
from flask_wtf import FlaskForm
from dmutils.forms import StripWhitespaceStringField

class NameForm(FlaskForm):
full_name = StripWhitespaceStringField()
--
# app/templates/name.html
{%
with
name = "full_name",
question = "What is your name?",
hint = "Enter your full name.",
value = form.full_name.data,
error = errors.get("full_name", {}).get("message", None)
%}
{% include "toolkit/forms/textbox.html" %}
{% endwith %}
```


New code:
```
# app/main/forms/form.py
from flask_wtf import FlaskForm
from dmutils.forms.dm_fields import DMStripWhitespaceStringField

class NameForm(FlaskForm):
full_name = DMStripWhitespaceStringField(
"What is your name?",
hint="Enter your full name.")
--
# app/templates/name.html
{{ form.full_name }}
```


Alternatively (expanded form):
```
# app/main/forms/form.py
from flask_wtf import FlaskForm
from dmutils.forms.dm_fields import DMStripWhitespaceStringField

class NameForm(FlaskForm):
full_name = DMStripWhitespaceStringField(
"What is your name?",
hint="Enter your full name.")
--
# app/templates/name.html
{%
with
name = form.full_name.name,
question = form.full_name.question,
hint = form.full_name.hint,
value = form.full_name.value,
error = form.full_name.error
%}
{% include "toolkit/forms/textbox.html" %}
{% endwith %}
```

## 41.0.0

PR [431](https://github.com/alphagov/digitalmarketplace-utils/pull/431)
Expand Down
2 changes: 1 addition & 1 deletion dmutils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
import flask_featureflags # noqa


__version__ = '41.2.1'
__version__ = '42.0.0'
4 changes: 0 additions & 4 deletions dmutils/forms/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +0,0 @@
from .fields import EmailField, StripWhitespaceStringField
from .filters import strip_whitespace
from .helpers import get_errors_from_wtform, remove_csrf_token
from .validators import EmailValidator
112 changes: 94 additions & 18 deletions dmutils/forms/fields.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,115 @@
'''
Fields for WTForms that are used within Digital Marketplace apps
This module includes classes that should be used with WTForms within Digital Marketplace apps.

They extend the classes found in `wtforms.fields`, and should be used instead of those.
The advantage they provide is that they have been deliberately designed to be used with
the Digital Marketplace frontend toolkit macros. If a particular field is missing, it
should be added to this file.

The main functionality is provided by the mixin class, `DMFieldMixin`. When a derived class
includes `DMFieldMixin` in its base classes then the following extra features are provided:

- a `hint` property that can be set in the initialiser
- a `question` property that contains the label text
- a `value` property for the data that is displayed
- an `error` property for the field validation error

For more details on how `DMFieldMixin`, see the documentation in `dmutils/forms/mixins.py`.
'''

import datetime
from itertools import chain

from wtforms import Field, Form, FormField
from wtforms.fields import IntegerField, StringField
import wtforms
import wtforms.fields
from wtforms.utils import unset_value
from wtforms.validators import Length

from .mixins import DMFieldMixin, DMSelectFieldMixin
from .widgets import (
DMCheckboxInput,
DMDateInput,
DMRadioInput,
DMTextInput,
DMUnitInput,
)

from .filters import strip_whitespace
from .validators import EmailValidator


class StripWhitespaceStringField(StringField):
__all__ = ['DMBooleanField', 'DMDecimalField', 'DMHiddenField', 'DMIntegerField',
'DMRadioField', 'DMStringField', 'DMEmailField', 'DMStripWhitespaceStringField',
'DMDateField']


class DMBooleanField(DMFieldMixin, wtforms.fields.BooleanField):
widget = DMCheckboxInput(hide_question=True)

type = "checkbox"

@property
def options(self):
return [{"label": self.label.text, "value": self.data}]


class DMDecimalField(DMFieldMixin, wtforms.fields.DecimalField):
pass


class DMPoundsField(DMDecimalField):
widget = DMUnitInput()

unit = "£"
unit_in_full = "pounds"
unit_position = "before"
hint = "For example, 9900.95 for 9900 pounds and 95 pence"


class DMHiddenField(DMFieldMixin, wtforms.fields.HiddenField):
pass


class DMIntegerField(DMFieldMixin, wtforms.fields.IntegerField):
pass


class DMRadioField(DMFieldMixin, DMSelectFieldMixin, wtforms.fields.RadioField):
widget = DMRadioInput()

type = "radio"


class DMStringField(DMFieldMixin, wtforms.fields.StringField):
widget = DMTextInput()


class DMStripWhitespaceStringField(DMFieldMixin, wtforms.fields.StringField):
widget = DMTextInput()

def __init__(self, label=None, **kwargs):
kwargs['filters'] = tuple(chain(
kwargs.get('filters', ()),
kwargs.get('filters', ()) or (),
(
strip_whitespace,
),
))
super(StringField, self).__init__(label, **kwargs)
super().__init__(label, **kwargs)


class EmailField(StripWhitespaceStringField):
class DMEmailField(DMStripWhitespaceStringField):
def __init__(self, label=None, **kwargs):
kwargs["validators"] = tuple(chain(
kwargs.pop("validators", ()),
kwargs.pop("validators", ()) or (),
(
EmailValidator(),
Length(max=511, message="Please enter an email address under 512 characters."),
),
))
super(EmailField, self).__init__(label, **kwargs)
super().__init__(label, **kwargs)


class DateField(Field):
class DMDateField(DMFieldMixin, wtforms.fields.Field):
'''
A date field(set) that uses a day, month and year field.

Expand All @@ -61,6 +133,10 @@ class DateField(Field):
datetime.date(1999, 12, 31)
'''

widget = DMDateInput()

hint = "For example, 31 12 2020"

# An internal class that defines the fields that make up the DateField.
#
# Inheriting from wtforms.FormField has limitations on using validators.
Expand All @@ -69,14 +145,14 @@ class DateField(Field):
# to turn the form data into integer values, and we then grab the data.
#
# The FormField instance is based on this class.
class _DateForm(Form):
day = IntegerField("Day")
month = IntegerField("Month")
year = IntegerField("Year")

def __init__(self, label=None, validators=None, separator='-', **kwargs):
super().__init__(label=label, validators=validators, **kwargs)
self.form_field = FormField(self._DateForm, separator=separator, **kwargs)
class _DateForm(wtforms.Form):
day = wtforms.fields.IntegerField("Day")
month = wtforms.fields.IntegerField("Month")
year = wtforms.fields.IntegerField("Year")

def __init__(self, label=None, validators=None, hint=None, separator='-', **kwargs):
super().__init__(label=label, validators=validators, hint=hint, **kwargs)
self.form_field = wtforms.fields.FormField(self._DateForm, separator=separator, **kwargs)

def _value(self):
'''
Expand Down
110 changes: 110 additions & 0 deletions dmutils/forms/mixins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'''
This module includes mixins that are designed to make it easier to use WTForms
with the Digital Marketplace frontend toolkit.

For example:

>>> from wtforms import Form, StringField
>>>
>>> # include the mixin in a new class
>>> class DMStringField(DMFieldMixin, StringField):
... pass
>>>
>>> # create a form with our new field class
>>> class TextForm(Form):
... text = DMStringField('Text field', hint='Type text here.')
>>>
>>> form = TextForm()
>>>
>>> # our new field has all the benefits of DMFieldMixin
>>> form.text.name
'text'
>>> form.text.question
'Text field'
>>> form.text.hint
'Type text here.'

For more examples of these mixins in action, see `dmutils/forms/dm_fields.py`.

For more about mixins in general, see this `online article`_,
or the very excellent `Python Cookbook`_ by David Beazley.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insert amazon affiliate link

Copy link
Contributor Author

@lfdebrux lfdebrux Aug 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also available at all good bookstores 😛


.. _online article:
https://zonca.github.io/2013/04/simple-mixin-usage-in-python.html
.. _Python Cookbook:
http://dabeaz.com/cookbook.html
'''

from copy import copy

from wtforms.compat import text_type
from wtforms.fields import SelectField


class DMFieldMixin:
'''
A mixin designed to make it easier to use WTForms
with the frontend toolkit. The idea is that the
properties mirror the definitions that the toolkit
template macros expect.

It adds the following features:
- a `hint` property that can be set in the initialiser
- a `question` property that contains the label text
- a `value` property for the data that is displayed
- an `error` property for the field validation error

Derived classes which include this mixin should have a
subclass of `wtforms.Field` in their base classes.
'''
def __init__(self, label=None, validators=None, hint=None, **kwargs):
super().__init__(label=label, validators=validators, **kwargs)
self.hint = hint or getattr(self.__class__, 'hint', None)
self.type = getattr(self.__class__, 'type', self.type)

@property
def question(self):
return self.label.text

@property
def value(self):
return self._value()

@property
def error(self):
try:
return self.errors[0]
except IndexError:
return None


class DMSelectFieldMixin(SelectField):
'''
A Digital Marketplace wrapper for selection fields.

The `options` argument for the constructor should be a dictionary with
`value`, label`, and `description` keys.

The `options` attribute is the choices in a format suitable for the
frontend toolkit.
'''
def __init__(self, label=None, validators=None, coerce=text_type, options=None, **kwargs):
super().__init__(label, validators, coerce, **kwargs)
self.options = copy(options)

@property
def choices(self):
return [(option['value'], option['label']) for option in self.options] if self.options else []

@choices.setter
def choices(self, value):
if value is None:
self.options = None
else:
for value, label in value:
self.options.append(
{
'label': label,
'value': value,
}
)
Loading