Skip to content

torchbox/tbxforms

Repository files navigation

PyPI npm PyPI downloads CI LGTM

Torchbox Forms

A Torchbox-flavoured template pack for django-crispy-forms, adapted from crispy-forms-gds.

Out of the box, forms created with tbxforms will look like the GOV.UK Design System, though many variables can be customised.

Requirements

  • python >=3.8.1,<4.0
  • Django >=2.2
  • django-crispy-forms >=1.13.0,<2.0
  • wagtail >=2.15 if using WagtailBaseForm
  • sass >=1.33.0 if building the sass yourself

Installation

You must install both the Python package and the NPM package.

Install the Python package

Install using pip:

pip install tbxforms

Add django-crispy-forms and tbxforms to your installed apps:

INSTALLED_APPS = [
  # ...
  'crispy_forms',  # django-crispy-forms
  'tbxforms',
]

Now add the following settings to tell django-crispy-forms to use tbxforms:

CRISPY_ALLOWED_TEMPLATE_PACKS = ["tbx"]
CRISPY_TEMPLATE_PACK = "tbx"

Install the NPM package

Install using NPM:

npm install tbxforms

Note: This package uses the Element.closest, NodeList.forEach, and Array.includes APIs. You will need to install and configure polyfills for legacy browser support if you need to.

Instantiate your forms:

import TbxForms from 'tbxforms';

document.addEventListener('DOMContentLoaded', () => {
    for (const form of document.querySelectorAll(TbxForms.selector())) {
        new TbxForms(form);
    }
});

Import the styles into your project...

...Either as CSS without any customisations:

@use 'node_modules/tbxforms/dist/style.css';

...Or as Sass to customise variables:

@use 'node_modules/tbxforms/tbxforms.scss' with (
    $tbxforms-error-colour: #f00,
    $tbxforms-text-colour: #000,
);

Alternatively, variables can be defined in a centralised variables SCSS such as tbxforms/static/sass/abstracts/_variables.scss.

Add button styles

tbxforms provides out-of-the-box GOV.UK Design System styles for everything except buttons, as styles for these probably exist within your project.

You will need to write button styles for the following classes:

  1. .tbxforms-button
  2. .tbxforms-button.tbxforms-button--primary
  3. .tbxforms-button.tbxforms-button--secondary
  4. .tbxforms-button.tbxforms-button--warning

Usage

tbxforms can be used for coded Django forms and editor-controlled Wagtail forms.

Django forms

All forms must inherit the TbxFormsMixin mixin, as well as specifying a Django base form class (e.g. forms.Form or forms.ModelForm)

from django import forms
from tbxforms.forms import TbxFormsMixin

class ExampleForm(TbxFormsMixin, forms.Form):
    ...

class ExampleModelForm(TbxFormsMixin, forms.ModelForm):
    ...

Wagtail forms

Create or update a Wagtail form

Wagtail forms must inherit from TbxFormsMixin and WagtailBaseForm.

from wagtail.contrib.forms.forms import BaseForm as WagtailBaseForm
from tbxforms.forms import TbxFormsMixin

class ExampleWagtailForm(TbxFormsMixin, WagtailBaseForm):
    ...

Instruct a Wagtail Page model to use your form

In your form definitions (e.g. forms.py):

from tbxforms.forms import BaseWagtailFormBuilder as TbxFormsBaseWagtailFormBuilder
from path.to.your.forms import ExampleWagtailForm

class WagtailFormBuilder(TbxFormsBaseWagtailFormBuilder):
    def get_form_class(self):
        return type(str("WagtailForm"), (ExampleWagtailForm,), self.formfields)

And in your form page models (e.g. models.py):

from path.to.your.forms import WagtailFormBuilder

class ExampleFormPage(...):
    ...
    form_builder = WagtailFormBuilder
    ...

Render a form

Just like Django Crispy Forms, you need to pass your form object to the {% crispy ... %} template tag, e.g.:

{% load crispy_forms_tags %}

<html>
    <body>
        {% crispy your_sexy_form %}
    </body>
</html>

FormHelpers

A FormHelper allows you to alter the rendering behaviour of forms.

Every form that inherits from TbxFormsMixin (i.e. every form within tbxforms) will have a FormHelper with the following default attributes:

  • html5_required = True
  • label_size = Size.MEDIUM
  • legend_size = Size.MEDIUM
  • form_error_title = _("There is a problem with your submission")
  • Plus everything from django-crispy-forms' default attributes.

These can be changed during instantiation or on the go - examples below.

Add a submit button

Submit buttons are not automatically added to forms. To add one, you can extend the form.helper.layout (examples below).

Extend during instantiation:

from django import forms
from tbxforms.forms import TbxFormsMixin
from tbxforms.layout import Button

class YourSexyForm(TbxFormsMixin, forms.Form):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper.layout.extend([
            Button.primary(
                name="submit",
                type="submit",
                value="Submit",
            )
        ])

Or afterwards:

from tbxforms.layout import Button

form = YourSexyForm()
form.helper.layout.extend([
    Button.primary(
        name="submit",
        type="submit",
        value="Submit",
    )
])

Conditionally-required fields

tbxforms can show/hide parts of the layout depending on a given value. For example, you could show (and require) an email address field only when the user chooses to sign up to a newsletter (examples below).

You can apply this logic to field, div, and fieldset elements.

Note: any field names included within the conditional_fields_to_show_as_required() method will appear on the frontend as required, though will technically be required=False.

Field example:

from django import forms
from django.core.exceptions import ValidationError
from tbxforms.choices import Choice
from tbxforms.forms import TbxFormsMixin
from tbxforms.layout import Field, Layout

class ExampleForm(TbxFormsMixin, forms.Form):
    NEWSLETTER_CHOICES = (
        Choice("yes", "Yes please", hint="Receive occasional email newsletters."),
        Choice("no", "No thanks"),
    )

    newsletter_signup = forms.ChoiceField(
        choices=NEWSLETTER_CHOICES
    )

    email = forms.EmailField(
        widget=forms.EmailInput(required=False)
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.helper.layout = Layout(
            # Add our newsletter sign-up field.
            Field.text("newsletter_signup"),

            # Add our email field and define the conditional logic.
            Field.text(
                "email",
                data_conditional={
                    "field_name": "newsletter_signup", # Field to inspect.
                    "values": ["yes"], # Value(s) to cause this field to show.
                },
            ),
        )

    @staticmethod
    def conditional_fields_to_show_as_required() -> [str]:
        # Non-required fields that should show as required to the user.
        return [
            "email",
        ]

    def clean(self):
        cleaned_data = super().clean()
        newsletter_signup = cleaned_data.get("newsletter_signup")
        email = cleaned_data.get("email")

        # Fields included within `conditional_fields_to_show_as_required()` will
        # be shown as required but not enforced - i.e. they will not have the
        # HTML5 `required` attribute set.
        # Thus we need to write our own check to enforce the value exists.
        if newsletter_signup == "yes" and not email:
            raise ValidationError(
                {
                    "email": "This field is required.",
                }
            )
        # The tbxforms JS will attempt to clear any redundant data upon submission,
        # though it is recommended to also handle this in your clean() method.
        elif newsletter_signup == "no" and email:
            del cleaned_data['email']

        return cleaned_data

Container example:

When you have multiple fields/elements that you want to show/hide together, you can use the exact same data_conditional definition as above but on a div or fieldset element, e.g.:

from tbxforms.layout import HTML, Div, Field, Layout

Layout(
    Div(
        HTML("<p>Some relevant text.</p>"),
        Field.text("some_other_field"),
        Field.text("email"),
        data_conditional={
            "field_name": "newsletter_signup",
            "values": ["yes"],
        },
    ),
)

Customising behaviour

Change the default label and legend classes

Possible values for the label_size and legend_size:

  1. SMALL
  2. MEDIUM (default)
  3. LARGE
  4. EXTRA_LARGE

Further reading