From 8ac580074dad564fe3a9a1a16e68dba32de663a7 Mon Sep 17 00:00:00 2001 From: Laurence de Bruxelles Date: Fri, 1 Jun 2018 15:59:26 +0100 Subject: [PATCH 1/8] Add a mixin that make it easier to use WTForms with frontend toolkit --- dmutils/forms/mixins.py | 50 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 dmutils/forms/mixins.py diff --git a/dmutils/forms/mixins.py b/dmutils/forms/mixins.py new file mode 100644 index 00000000..98e49efc --- /dev/null +++ b/dmutils/forms/mixins.py @@ -0,0 +1,50 @@ + + +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 + + It needs to be used with `wtforms.Field`. + + Usage: + >>> from wtforms import Form, StringField + >>> class DMStringField(DMFieldMixin, StringField): + ... pass + >>> class TextForm(Form): + ... text = DMStringField('Text field', hint='Type text here.') + >>> form = TextForm() + >>> form.text.name + 'text' + >>> form.text.question + 'Text field' + >>> form.text.hint + 'Type text here.' + ''' + def __init__(self, label=None, validators=None, hint=None, **kwargs): + self.hint = hint + + super().__init__(label=label, validators=validators, **kwargs) + + @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 From e6373097faef74a11f28e063bb733a042d282bca Mon Sep 17 00:00:00 2001 From: Laurence de Bruxelles Date: Mon, 4 Jun 2018 10:58:54 +0100 Subject: [PATCH 2/8] Add DM wrappers for the most commonly used WTForms fields We create 'DM' versions of common WTForms fields that have the DMFieldMixin in their MRO. We also remove our non-DM versions of custom fields, so this is a breaking change --- dmutils/forms/__init__.py | 4 -- dmutils/forms/fields.py | 111 ++++++++++++++++++++++++----- dmutils/forms/mixins.py | 53 ++++++++++---- tests/forms/test_dm_radio_field.py | 32 +++++++++ tests/forms/test_dmfields.py | 26 +++++++ tests/test_date_field.py | 34 ++++----- tests/test_forms.py | 15 ++-- 7 files changed, 214 insertions(+), 61 deletions(-) create mode 100644 tests/forms/test_dm_radio_field.py create mode 100644 tests/forms/test_dmfields.py diff --git a/dmutils/forms/__init__.py b/dmutils/forms/__init__.py index f7d98a32..e69de29b 100644 --- a/dmutils/forms/__init__.py +++ b/dmutils/forms/__init__.py @@ -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 diff --git a/dmutils/forms/fields.py b/dmutils/forms/fields.py index 2eeb3664..ea7a31a9 100644 --- a/dmutils/forms/fields.py +++ b/dmutils/forms/fields.py @@ -1,43 +1,118 @@ ''' -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 + 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): + type = "checkbox" + + @property + def options(self): + return [{"label": self.label.text, "value": self.data}] + + +class DMDecimalField(DMFieldMixin, wtforms.fields.DecimalField): + pass + + +class DMHiddenField(DMFieldMixin, wtforms.fields.HiddenField): + pass + + +class DMIntegerField(DMFieldMixin, wtforms.fields.IntegerField): + pass + + +class DMRadioField(DMFieldMixin, wtforms.fields.RadioField): + ''' + A Digital Marketplace wrapper for `wtforms.RadioField`. + + The `choices` argument for the constructor should be a sequence of + `(value, label, description)` tuples. + + The `options` property is the choices in a format suitable for the + frontend toolkit. + ''' + def __init__(self, label=None, validators=None, choices=None, **kwargs): + if choices: + # We do this the long way rather than using a comprehension because + # we want to be able to accept (value, label) tuples as well. + self.options = [] + for choice in choices: + option = {} + option['value'] = choice[0] + option['label'] = choice[1] + if len(choice) > 2 and choice[2]: + option['description'] = choice[2] + self.options.append(option) + + # construct a choices argument suitable for wtforms.fields.RadioField + choices = [(option['value'], option['label']) for option in self.options] + + super().__init__(label, validators, choices=choices, **kwargs) + + +class DMStringField(DMFieldMixin, wtforms.fields.StringField): + pass + + +class DMStripWhitespaceStringField(DMFieldMixin, wtforms.fields.StringField): 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. @@ -69,14 +144,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): ''' diff --git a/dmutils/forms/mixins.py b/dmutils/forms/mixins.py index 98e49efc..1c3c1d48 100644 --- a/dmutils/forms/mixins.py +++ b/dmutils/forms/mixins.py @@ -1,3 +1,39 @@ +''' +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. + +.. _online article: + https://zonca.github.io/2013/04/simple-mixin-usage-in-python.html +.. _Python Cookbook: + http://dabeaz.com/cookbook.html +''' class DMFieldMixin: @@ -13,21 +49,8 @@ class DMFieldMixin: - a `value` property for the data that is displayed - an `error` property for the field validation error - It needs to be used with `wtforms.Field`. - - Usage: - >>> from wtforms import Form, StringField - >>> class DMStringField(DMFieldMixin, StringField): - ... pass - >>> class TextForm(Form): - ... text = DMStringField('Text field', hint='Type text here.') - >>> form = TextForm() - >>> form.text.name - 'text' - >>> form.text.question - 'Text field' - >>> form.text.hint - 'Type text here.' + 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): self.hint = hint diff --git a/tests/forms/test_dm_radio_field.py b/tests/forms/test_dm_radio_field.py new file mode 100644 index 00000000..e49b2eb1 --- /dev/null +++ b/tests/forms/test_dm_radio_field.py @@ -0,0 +1,32 @@ +import pytest + +import wtforms + +from dmutils.forms.fields import DMRadioField + +_choices = ( + ('yes', 'Yes', 'A positive response.'), + ('no', 'No', 'A negative response.'), +) + + +class RadioForm(wtforms.Form): + field = DMRadioField(choices=_choices) + + +@pytest.fixture +def form(): + return RadioForm() + + +def test_dm_radio_field_has_options_property(form): + assert form.field.options + + +def test_options_is_a_list_of_dicts(form): + assert isinstance(form.field.options, list) + assert all(isinstance(option, dict) for option in form.field.options) + + +def test_an_option_can_have_a_description(form): + assert form.field.options[0]['description'] diff --git a/tests/forms/test_dmfields.py b/tests/forms/test_dmfields.py new file mode 100644 index 00000000..ea3483b8 --- /dev/null +++ b/tests/forms/test_dmfields.py @@ -0,0 +1,26 @@ + +import pytest + +import wtforms + +import dmutils.forms.fields as dm_fields + + +@pytest.fixture(params=dm_fields.__all__) +def field_class(request): + return getattr(dm_fields, request.param) + + +def test_field_can_be_class_property(field_class): + class TestForm(wtforms.Form): + field = field_class() + + assert TestForm() + + +def test_field_has_hint_property(field_class): + class TestForm(wtforms.Form): + field = field_class(hint='Hint text.') + + form = TestForm() + assert form.field.hint == 'Hint text.' diff --git a/tests/test_date_field.py b/tests/test_date_field.py index f6afc751..481814ba 100644 --- a/tests/test_date_field.py +++ b/tests/test_date_field.py @@ -5,14 +5,14 @@ from wtforms import Form from wtforms.validators import DataRequired, InputRequired -from dmutils.forms.fields import DateField +from dmutils.forms.fields import DMDateField from dmutils.forms.validators import GreaterThan, FourDigitYear import datetime class DateForm(Form): - date = DateField() + date = DMDateField() def _data_fromtuple(t, prefix='date'): @@ -58,11 +58,11 @@ def invalid_data(request): def test_create_date_field_with_validators(): - assert DateField(validators=[InputRequired(), DataRequired()]) + assert DMDateField(validators=[InputRequired(), DataRequired()]) def test_date_field_has__value_method(): - assert DateField._value + assert DMDateField._value def test_date_field__value_method_returns_raw_data(invalid_data): @@ -122,7 +122,7 @@ def test_field__value_before_input_is_empty_dict(): )) def test_data_required_error_message_matches_validation_message(invalid_data, message): class DateForm(Form): - date = DateField(validators=[DataRequired(message)]) + date = DMDateField(validators=[DataRequired(message)]) form = DateForm(invalid_data) form.validate() @@ -140,7 +140,7 @@ class DateForm(Form): )) def test_input_required_error_message_matches_validation_message(message, empty): class DateForm(Form): - date = DateField(validators=[InputRequired(message)]) + date = DMDateField(validators=[InputRequired(message)]) empty = MultiDict(empty) form = DateForm(empty) @@ -154,7 +154,7 @@ class DateForm(Form): )) def test_error_message_with_multiple_validators(invalid_data, message): class DateForm(Form): - date = DateField(validators=[InputRequired(), DataRequired(message)]) + date = DMDateField(validators=[InputRequired(), DataRequired(message)]) form = DateForm(invalid_data) form.validate() @@ -163,7 +163,7 @@ class DateForm(Form): def test_date_field_with_input_required_validator(valid_data): class DateForm(Form): - date = DateField(validators=[InputRequired()]) + date = DMDateField(validators=[InputRequired()]) form = DateForm(valid_data) assert form.validate() @@ -171,7 +171,7 @@ class DateForm(Form): def test_date_field_with_data_required_validator(valid_data): class DateForm(Form): - date = DateField(validators=[DataRequired()]) + date = DMDateField(validators=[DataRequired()]) form = DateForm(valid_data) assert form.validate() @@ -187,8 +187,8 @@ class DateForm(Form): )) def test_date_field_with_greater_than_validator(valid_data, timedelta): class DateForm(Form): - past = DateField() - date = DateField(validators=[GreaterThan('past')]) + past = DMDateField() + date = DMDateField(validators=[GreaterThan('past')]) # test validator being triggered future = DateForm(valid_data).date.data + timedelta @@ -214,8 +214,8 @@ class DateForm(Form): def test_date_field_with_greater_than_validator_missing_past(valid_data): class DateForm(Form): - past = DateField() - date = DateField(validators=[GreaterThan('invalid_key')]) + past = DMDateField() + date = DMDateField(validators=[GreaterThan('invalid_key')]) form = DateForm(valid_data) assert not form.validate() @@ -224,8 +224,8 @@ class DateForm(Form): def test_date_field_with_greater_than_validator_key_error(valid_data): class DateForm(Form): - past = DateField() - date = DateField(validators=[GreaterThan('invalid_key')]) + past = DMDateField() + date = DMDateField(validators=[GreaterThan('invalid_key')]) future = DateForm(valid_data).date.data - datetime.timedelta(days=1) future_data = _data_fromdate(future, 'past') @@ -243,7 +243,7 @@ class DateForm(Form): )) def test_date_form_with_y2k_validator(two_digit_date): class DateForm(Form): - date = DateField(validators=[FourDigitYear()]) + date = DMDateField(validators=[FourDigitYear()]) invalid_data = _data_fromtuple(two_digit_date) form = DateForm(invalid_data) @@ -253,7 +253,7 @@ class DateForm(Form): def test_date_form_with_y2k_validator_accepts_four_digit_dates(valid_data): class DateForm(Form): - date = DateField(validators=[FourDigitYear()]) + date = DMDateField(validators=[FourDigitYear()]) form = DateForm(valid_data) assert form.validate() diff --git a/tests/test_forms.py b/tests/test_forms.py index f3d99a52..dbc8d140 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -7,15 +7,16 @@ from werkzeug.datastructures import ImmutableMultiDict import pytest -from dmutils.forms import EmailField, remove_csrf_token, get_errors_from_wtform +from dmutils.forms.fields import DMEmailField +from dmutils.forms.helpers import remove_csrf_token, get_errors_from_wtform class EmailFieldFormatTestForm(FlaskForm): - test_email = EmailField("An Electronic Mailing Address") + test_email = DMEmailField("An Electronic Mailing Address") class MultipleFieldFormatTestForm(FlaskForm): - test_one = EmailField("Email address") + test_one = DMEmailField("Email address") test_two = StringField("Test Two", validators=[DataRequired(message="Enter text.")]) test_three = StringField("Test Three", validators=[Length(min=100, max=100, message="Bad length.")]) test_four = StringField("Test Four", validators=[DataRequired(message="Enter text.")]) @@ -89,7 +90,7 @@ def test_default_email_length(self, app): def test_default_length_and_message_can_be_overridden(self, app): class OverrideDefaultValidatorTestForm(FlaskForm): - test_email = EmailField( + test_email = DMEmailField( "An Electronic Mailing Address", validators=[Length(max=11, message='Only really short emails please')] ) @@ -108,15 +109,15 @@ class OverrideDefaultValidatorTestForm(FlaskForm): class EmailFieldCombinationTestForm(FlaskForm): - required_email = EmailField( + required_email = DMEmailField( "Required Electronic Mailing Address", validators=[DataRequired(message="No really, we want this")], ) - optional_email = EmailField( + optional_email = DMEmailField( "Optional Electronic Mailing Address", validators=[Optional()], ) - unspecified_email = EmailField("Voluntary Electronic Mailing Address") + unspecified_email = DMEmailField("Voluntary Electronic Mailing Address") class TestEmailFieldCombination(object): From 9a683f6c732276ef86c736533967c6f710c1e29a Mon Sep 17 00:00:00 2001 From: Laurence de Bruxelles Date: Thu, 5 Jul 2018 17:50:25 +0100 Subject: [PATCH 3/8] Add default hints --- dmutils/forms/fields.py | 9 +++++++++ dmutils/forms/mixins.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/dmutils/forms/fields.py b/dmutils/forms/fields.py index ea7a31a9..cd8152f2 100644 --- a/dmutils/forms/fields.py +++ b/dmutils/forms/fields.py @@ -48,6 +48,13 @@ class DMDecimalField(DMFieldMixin, wtforms.fields.DecimalField): pass +class DMPoundsField(DMDecimalField): + 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 @@ -136,6 +143,8 @@ class DMDateField(DMFieldMixin, wtforms.fields.Field): datetime.date(1999, 12, 31) ''' + 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. diff --git a/dmutils/forms/mixins.py b/dmutils/forms/mixins.py index 1c3c1d48..919b7aef 100644 --- a/dmutils/forms/mixins.py +++ b/dmutils/forms/mixins.py @@ -53,7 +53,7 @@ class DMFieldMixin: subclass of `wtforms.Field` in their base classes. ''' def __init__(self, label=None, validators=None, hint=None, **kwargs): - self.hint = hint + self.hint = hint or getattr(self.__class__, 'hint', None) super().__init__(label=label, validators=validators, **kwargs) From dde2e98cfef5c2a98d6c1f32d34a9ad83afd660b Mon Sep 17 00:00:00 2001 From: Laurence de Bruxelles Date: Mon, 9 Jul 2018 16:43:55 +0100 Subject: [PATCH 4/8] Add type attribute to form fields --- dmutils/forms/mixins.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dmutils/forms/mixins.py b/dmutils/forms/mixins.py index 919b7aef..e3150753 100644 --- a/dmutils/forms/mixins.py +++ b/dmutils/forms/mixins.py @@ -53,9 +53,9 @@ class DMFieldMixin: subclass of `wtforms.Field` in their base classes. ''' def __init__(self, label=None, validators=None, hint=None, **kwargs): - self.hint = hint or getattr(self.__class__, 'hint', None) - 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): From 72116eab3410474962a08e878a186870d674c641 Mon Sep 17 00:00:00 2001 From: Laurence de Bruxelles Date: Mon, 9 Jul 2018 17:47:12 +0100 Subject: [PATCH 5/8] Add DMSelectFieldMixin for making DMRadioField --- dmutils/forms/fields.py | 31 +++---------------------- dmutils/forms/mixins.py | 37 ++++++++++++++++++++++++++++++ tests/forms/test_dm_radio_field.py | 18 +++++++++++---- 3 files changed, 53 insertions(+), 33 deletions(-) diff --git a/dmutils/forms/fields.py b/dmutils/forms/fields.py index cd8152f2..3e46413e 100644 --- a/dmutils/forms/fields.py +++ b/dmutils/forms/fields.py @@ -25,7 +25,7 @@ from wtforms.utils import unset_value from wtforms.validators import Length -from .mixins import DMFieldMixin +from .mixins import DMFieldMixin, DMSelectFieldMixin from .filters import strip_whitespace from .validators import EmailValidator @@ -63,33 +63,8 @@ class DMIntegerField(DMFieldMixin, wtforms.fields.IntegerField): pass -class DMRadioField(DMFieldMixin, wtforms.fields.RadioField): - ''' - A Digital Marketplace wrapper for `wtforms.RadioField`. - - The `choices` argument for the constructor should be a sequence of - `(value, label, description)` tuples. - - The `options` property is the choices in a format suitable for the - frontend toolkit. - ''' - def __init__(self, label=None, validators=None, choices=None, **kwargs): - if choices: - # We do this the long way rather than using a comprehension because - # we want to be able to accept (value, label) tuples as well. - self.options = [] - for choice in choices: - option = {} - option['value'] = choice[0] - option['label'] = choice[1] - if len(choice) > 2 and choice[2]: - option['description'] = choice[2] - self.options.append(option) - - # construct a choices argument suitable for wtforms.fields.RadioField - choices = [(option['value'], option['label']) for option in self.options] - - super().__init__(label, validators, choices=choices, **kwargs) +class DMRadioField(DMFieldMixin, DMSelectFieldMixin, wtforms.fields.RadioField): + type = "radio" class DMStringField(DMFieldMixin, wtforms.fields.StringField): diff --git a/dmutils/forms/mixins.py b/dmutils/forms/mixins.py index e3150753..64285080 100644 --- a/dmutils/forms/mixins.py +++ b/dmutils/forms/mixins.py @@ -35,6 +35,11 @@ http://dabeaz.com/cookbook.html ''' +from copy import copy + +from wtforms.compat import text_type +from wtforms.fields import SelectField + class DMFieldMixin: ''' @@ -71,3 +76,35 @@ def error(self): 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, + } + ) diff --git a/tests/forms/test_dm_radio_field.py b/tests/forms/test_dm_radio_field.py index e49b2eb1..502a240e 100644 --- a/tests/forms/test_dm_radio_field.py +++ b/tests/forms/test_dm_radio_field.py @@ -4,14 +4,22 @@ from dmutils.forms.fields import DMRadioField -_choices = ( - ('yes', 'Yes', 'A positive response.'), - ('no', 'No', 'A negative response.'), -) +_options = [ + { + "label": "Yes", + "value": "yes", + "description": "A positive response." + }, + { + "label": "No", + "value": "no", + "description": "A negative response." + } +] class RadioForm(wtforms.Form): - field = DMRadioField(choices=_choices) + field = DMRadioField(options=_options) @pytest.fixture From 7ce10f65bf8ca0b02228829d5fa99e3c8e1f55da Mon Sep 17 00:00:00 2001 From: Laurence de Bruxelles Date: Tue, 10 Jul 2018 10:05:55 +0100 Subject: [PATCH 6/8] Add widgets for rendering WTForm fields from Jinja This commit adds widgets for WTForms that render field html based on Jinja templates. By default the templates used are those in digitalmarketplace-frontend-toolkit. --- dmutils/forms/fields.py | 19 ++++++++++- dmutils/forms/widgets.py | 69 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 dmutils/forms/widgets.py diff --git a/dmutils/forms/fields.py b/dmutils/forms/fields.py index 3e46413e..afbe4271 100644 --- a/dmutils/forms/fields.py +++ b/dmutils/forms/fields.py @@ -26,6 +26,13 @@ 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 @@ -37,6 +44,8 @@ class DMBooleanField(DMFieldMixin, wtforms.fields.BooleanField): + widget = DMCheckboxInput() + type = "checkbox" @property @@ -49,6 +58,8 @@ class DMDecimalField(DMFieldMixin, wtforms.fields.DecimalField): class DMPoundsField(DMDecimalField): + widget = DMUnitInput() + unit = "£" unit_in_full = "pounds" unit_position = "before" @@ -64,14 +75,18 @@ class DMIntegerField(DMFieldMixin, wtforms.fields.IntegerField): class DMRadioField(DMFieldMixin, DMSelectFieldMixin, wtforms.fields.RadioField): + widget = DMRadioInput() + type = "radio" class DMStringField(DMFieldMixin, wtforms.fields.StringField): - pass + widget = DMTextInput() class DMStripWhitespaceStringField(DMFieldMixin, wtforms.fields.StringField): + widget = DMTextInput() + def __init__(self, label=None, **kwargs): kwargs['filters'] = tuple(chain( kwargs.get('filters', ()) or (), @@ -118,6 +133,8 @@ class DMDateField(DMFieldMixin, wtforms.fields.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. diff --git a/dmutils/forms/widgets.py b/dmutils/forms/widgets.py new file mode 100644 index 00000000..5a291b59 --- /dev/null +++ b/dmutils/forms/widgets.py @@ -0,0 +1,69 @@ + +from flask import current_app + +__all__ = ["DMCheckboxInput", "DMDateInput", "DMRadioInput", "DMTextInput", "DMUnitInput"] + + +class DMJinjaWidgetBase: + + template_args = [] + + def __init__(self): + # we include common template arguments here to avoid repetition + self.template_args = ["error", "name", "hint", "question", "value"] + self.template_args + self.template = None + + def __call__(self, field, **kwargs): + # get the template variables from the field + for attr in self.template_args: + if hasattr(field, attr): + kwargs.setdefault(attr, getattr(field, attr)) + + # cache the template + # this cannot be done in __init__ as the flask app may not exist + if not self.template: + self.template = current_app.jinja_env.get_template(self.template_file) + + html = self.template.render(**kwargs) + return html + + +class DMSelectionButtonBase(DMJinjaWidgetBase): + template_args = ["type", "inline", "options"] + template_file = "toolkit/forms/selection-buttons.html" + + def __init__(self): + super().__init__() + self.template_args.remove("value") + + def __call__(self, field, **kwargs): + kwargs["type"] = self.type + return super().__call__(field, **kwargs) + + +class DMCheckboxInput(DMSelectionButtonBase): + type = "checkbox" + + +class DMDateInput(DMJinjaWidgetBase): + template_file = "toolkit/forms/date.html" + + def __init__(self): + super().__init__() + self.template_args.remove("value") + + def __call__(self, field, **kwargs): + kwargs["data"] = field.value + return super().__call__(field, **kwargs) + + +class DMRadioInput(DMSelectionButtonBase): + type = "radio" + + +class DMTextInput(DMJinjaWidgetBase): + template_file = "toolkit/forms/textbox.html" + + +class DMUnitInput(DMTextInput): + template_args = ["unit_in_full", "unit", "unit_position"] From e99deab424545b9971dc9a55a7046d82f7cf6c06 Mon Sep 17 00:00:00 2001 From: Laurence de Bruxelles Date: Wed, 11 Jul 2018 11:05:45 +0100 Subject: [PATCH 7/8] Add the ability to suppress question labels on form widgets --- dmutils/forms/fields.py | 2 +- dmutils/forms/widgets.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/dmutils/forms/fields.py b/dmutils/forms/fields.py index afbe4271..e38418c1 100644 --- a/dmutils/forms/fields.py +++ b/dmutils/forms/fields.py @@ -44,7 +44,7 @@ class DMBooleanField(DMFieldMixin, wtforms.fields.BooleanField): - widget = DMCheckboxInput() + widget = DMCheckboxInput(hide_question=True) type = "checkbox" diff --git a/dmutils/forms/widgets.py b/dmutils/forms/widgets.py index 5a291b59..a5817fa6 100644 --- a/dmutils/forms/widgets.py +++ b/dmutils/forms/widgets.py @@ -8,9 +8,12 @@ class DMJinjaWidgetBase: template_args = [] - def __init__(self): + def __init__(self, hide_question=False): # we include common template arguments here to avoid repetition self.template_args = ["error", "name", "hint", "question", "value"] + self.template_args + if hide_question: + self.template_args.remove("question") + self.template = None def __call__(self, field, **kwargs): @@ -32,8 +35,8 @@ class DMSelectionButtonBase(DMJinjaWidgetBase): template_args = ["type", "inline", "options"] template_file = "toolkit/forms/selection-buttons.html" - def __init__(self): - super().__init__() + def __init__(self, **kwargs): + super().__init__(**kwargs) self.template_args.remove("value") def __call__(self, field, **kwargs): From 84902c5b2080fc9fc13fba51f50b838acfc23361 Mon Sep 17 00:00:00 2001 From: Laurence de Bruxelles Date: Mon, 6 Aug 2018 12:31:37 +0100 Subject: [PATCH 8/8] Version bump (major) --- CHANGELOG.md | 75 +++++++++++++++++++++++++++++++++++++++++++++ dmutils/__init__.py | 2 +- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61aaeb5d..877a8187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/dmutils/__init__.py b/dmutils/__init__.py index 45bc908a..4e881c3b 100644 --- a/dmutils/__init__.py +++ b/dmutils/__init__.py @@ -4,4 +4,4 @@ import flask_featureflags # noqa -__version__ = '41.2.1' +__version__ = '42.0.0'