Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions manage_breast_screening/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from os import environ
from pathlib import Path

import django
from dotenv import load_dotenv
from jinja2 import ChainableUndefined

Expand Down Expand Up @@ -88,7 +89,7 @@ def boolean_env(key, default=None):
TEMPLATES = [
{
"BACKEND": "django.template.backends.jinja2.Jinja2",
"DIRS": [BASE_DIR / "jinja2"],
"DIRS": [BASE_DIR / "jinja2", django.__path__[0] + "/forms/jinja2"],
"APP_DIRS": True,
"OPTIONS": {
"environment": "manage_breast_screening.config.jinja2_env.environment",
Expand All @@ -111,7 +112,7 @@ def boolean_env(key, default=None):
]

WSGI_APPLICATION = "manage_breast_screening.config.wsgi.application"

FORM_RENDERER = "django.forms.renderers.TemplatesSetting"

# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases
Expand Down
35 changes: 34 additions & 1 deletion manage_breast_screening/core/form_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from django import forms
from django.core import validators
from django.forms import ValidationError, widgets
from django.forms import Textarea, ValidationError, widgets
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _

Expand Down Expand Up @@ -141,3 +141,36 @@ def widget_attrs(self, widget):
if subfield.max_value is not None:
subwidget.attrs["max"] = subfield.max_value
return attrs


class CharField(forms.CharField):
def __init__(
self,
*args,
hint=None,
label_classes=None,
classes=None,
**kwargs,
):
widget = kwargs.get("widget")
if (isinstance(widget, type) and widget is Textarea) or isinstance(
widget, Textarea
):
kwargs["template_name"] = "forms/textarea.jinja"
else:
kwargs["template_name"] = "forms/input.jinja"

self.hint = hint
self.classes = classes
self.label_classes = label_classes

super().__init__(*args, **kwargs)

def widget_attrs(self, widget):
attrs = super().widget_attrs(widget)

# Don't use maxlength even if there is a max length validator.
# This attribute prevents the user from seeing errors, so we don't use it
attrs.pop("maxlength", None)

return attrs
28 changes: 28 additions & 0 deletions manage_breast_screening/core/jinja2/forms/input.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{% from "input/macro.jinja" import input %}
{% set unbound_field = field.field %}
{% set widget = unbound_field.widget %}
{% set input_params = {
"label": {
"text": field.label,
"classes": unbound_field.label_classes if unbound_field.label_classes
},
"hint": {
"text": unbound_field.hint
} if unbound_field.hint,
"id": field.auto_id,
"name": field.html_name,
"value": field.value() or "",
"classes": unbound_field.classes if unbound_field.classes,
"attributes": widget.attrs,
"type": widget.input_type
} %}
{% if field.errors %}
{% set error_params = {
"errorMessage": {
"text": field.errors | first
}
} %}
{% set input_params = dict(input_params, **error_params) %}
{% endif %}

{{ input(input_params) }}
30 changes: 30 additions & 0 deletions manage_breast_screening/core/jinja2/forms/textarea.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{% from "textarea/macro.jinja" import textarea %}
{% set unbound_field = field.field %}
{% set widget = unbound_field.widget %}
{% set textarea_params = {
"label": {
"text": field.label,
"classes": unbound_field.label_classes if unbound_field.label_classes
},
"hint": {
"text": unbound_field.hint
} if unbound_field.hint,
"id": field.auto_id,
"name": field.html_name,
"value": field.value() or "",
"classes": unbound_field.classes if unbound_field.classes,
"rows": widget.attrs.pop("rows", none),
"cols": widget.attrs.pop("cols", none),
"attributes": widget.attrs
} %}
{% if field.errors %}
{% set error_params = {
"errorMessage": {
"text": field.errors | first
}
} %}
{% set textarea_params = dict(textarea_params, **error_params) %}
{% endif %}

{{ textarea(textarea_params) }}

176 changes: 175 additions & 1 deletion manage_breast_screening/core/tests/test_form_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
import pytest
from django.core.exceptions import ValidationError
from django.forms import Form
from django.forms.widgets import TelInput, Textarea, TextInput
from pytest_django.asserts import assertHTMLEqual

from ..form_fields import SplitDateField
from ..form_fields import CharField, SplitDateField


class TestSplitDateField:
Expand Down Expand Up @@ -172,3 +173,176 @@ class TestForm(Form):
f = TestForm({"date_0": "1", "date_1": "8", "date_2": "2026"})
assert not f.is_valid()
assert f.errors == {"date": ["Enter a date before 1 July 2026"]}


class TestCharField:
@pytest.fixture
def form_class(self):
class TestForm(Form):
field = CharField(label="Abc", initial="somevalue", max_length=10)
field_with_visually_hidden_label = CharField(
label="Abc",
initial="somevalue",
label_classes="nhsuk-u-visually-hidden",
)
field_with_hint = CharField(
label="With hint", initial="", hint="ALL UPPERCASE"
)
field_with_classes = CharField(
label="With classes", initial="", classes="nhsuk-u-width-two-thirds"
)
field_with_extra_attrs = CharField(
label="Extra",
widget=TextInput(
attrs=dict(
autocomplete="off",
inputmode="numeric",
spellcheck="false",
autocapitalize="none",
pattern=r"\d{3}",
)
),
)
telephone_field = CharField(label="Ring ring", widget=TelInput)
textfield = CharField(
label="Text",
widget=Textarea(
attrs={
"rows": "3",
"autocomplete": "autocomplete",
"spellcheck": "true",
}
),
)
textfield_simple = CharField(label="Text", widget=Textarea)

return TestForm

def test_renders_nhs_input(self, form_class):
assertHTMLEqual(
form_class()["field"].as_field_group(),
"""
<div class="nhsuk-form-group">
<label class="nhsuk-label" for="id_field">
Abc
</label><input class="nhsuk-input" id="id_field" name="field" type="text" value="somevalue">
</div>
""",
)

def test_renders_nhs_input_with_visually_hidden_label(self, form_class):
assertHTMLEqual(
form_class()["field_with_visually_hidden_label"].as_field_group(),
"""
<div class="nhsuk-form-group">
<label class="nhsuk-label nhsuk-u-visually-hidden" for="id_field_with_visually_hidden_label">
Abc
</label><input class="nhsuk-input" id="id_field_with_visually_hidden_label" name="field_with_visually_hidden_label" type="text" value="somevalue">
</div>
""",
)

def test_renders_nhs_input_with_hint(self, form_class):
assertHTMLEqual(
form_class()["field_with_hint"].as_field_group(),
"""
<div class="nhsuk-form-group">
<label class="nhsuk-label" for="id_field_with_hint">
With hint
</label>
<div class="nhsuk-hint" id="id_field_with_hint-hint">ALL UPPERCASE</div>
<input class="nhsuk-input" id="id_field_with_hint" name="field_with_hint" type="text" aria-describedby="id_field_with_hint-hint">
</div>
""",
)

def test_renders_nhs_input_with_classes(self, form_class):
assertHTMLEqual(
form_class()["field_with_classes"].as_field_group(),
"""
<div class="nhsuk-form-group">
<label class="nhsuk-label" for="id_field_with_classes">
With classes
</label>
<input class="nhsuk-input nhsuk-u-width-two-thirds" id="id_field_with_classes" name="field_with_classes" type="text">
</div>
""",
)

def test_renders_nhs_input_with_extra_attrs(self, form_class):
assertHTMLEqual(
form_class()["field_with_extra_attrs"].as_field_group(),
"""
<div class="nhsuk-form-group">
<label class="nhsuk-label" for="id_field_with_extra_attrs">
Extra
</label>
<input autocomplete="off" autocapitalize="none" spellcheck="false" inputmode="numeric" pattern="\\d{3}" class="nhsuk-input" id="id_field_with_extra_attrs" name="field_with_extra_attrs" type="text">
</div>
""",
)

def test_bound_value_reflected_in_html_value(self, form_class):
assertHTMLEqual(
form_class({"field": "othervalue"})["field"].as_field_group(),
"""
<div class="nhsuk-form-group">
<label class="nhsuk-label" for="id_field">
Abc
</label><input class="nhsuk-input" id="id_field" name="field" type="text" value="othervalue">
</div>
""",
)

def test_invalid_value_renders_validation_error(self, form_class):
assertHTMLEqual(
form_class({"field": "reallylongvalue"})["field"].as_field_group(),
"""
<div class="nhsuk-form-group nhsuk-form-group--error">
<label class="nhsuk-label" for="id_field">
Abc
</label>
<span class="nhsuk-error-message" id="id_field-error">
<span class="nhsuk-u-visually-hidden">Error:</span> Ensure this value has at most 10 characters (it has 15).</span>
<input class="nhsuk-input nhsuk-input--error" id="id_field" name="field" type="text" value="reallylongvalue" aria-describedby="id_field-error">
</div>
""",
)

def test_telinput_renders_input_with_type_tel(self, form_class):
assertHTMLEqual(
form_class()["telephone_field"].as_field_group(),
"""
<div class="nhsuk-form-group">
<label class="nhsuk-label" for="id_telephone_field">
Ring ring
</label><input type="tel" class="nhsuk-input" id="id_telephone_field" name="telephone_field">
</div>
""",
)

def test_textarea_renders_textarea(self, form_class):
assertHTMLEqual(
form_class()["textfield"].as_field_group(),
"""
<div class="nhsuk-form-group">
<label class="nhsuk-label" for="id_textfield">
Text
</label>
<textarea class="nhsuk-textarea" id="id_textfield" name="textfield" rows=" 3 " autocomplete="autocomplete" spellcheck="true"></textarea>
</div>
""",
)

def test_textarea_class_renders_textarea(self, form_class):
assertHTMLEqual(
form_class()["textfield_simple"].as_field_group(),
"""
<div class="nhsuk-form-group">
<label class="nhsuk-label" for="id_textfield_simple">
Text
</label>
<textarea class="nhsuk-textarea" id="id_textfield_simple" name="textfield_simple" rows=" 10 "></textarea>
</div>
""",
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django import forms

from manage_breast_screening.core.form_fields import CharField
from manage_breast_screening.participants.models import AppointmentStatus


Expand All @@ -26,7 +27,9 @@ def __init__(self, *args, **kwargs):

# Dynamically add detail fields for each choice
for field_name, _ in self.STOPPED_REASON_CHOICES:
self.fields[f"{field_name}_details"] = forms.CharField(required=False)
self.fields[f"{field_name}_details"] = CharField(
required=False, label="Provide details"
)

# Ensure that the field order matches the order we want to render in
details_fields = [
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from django import forms
from django.db.models import TextChoices
from django.forms import Textarea

from manage_breast_screening.core.form_fields import CharField
from manage_breast_screening.participants.models import SupportReasons


Expand Down Expand Up @@ -35,7 +37,13 @@ def __init__(self, *args, **kwargs):
# Each support reason has an associated field for details.
for option in self.SupportReasons:
field_name = option.value.lower() + "_details"
self.fields[field_name] = forms.CharField(required=False, initial="")
self.fields[field_name] = CharField(
required=False,
initial="",
label="Describe support required",
widget=Textarea,
hint=self.SupportReasonHints.get(option),
)

self.fields["any_temporary"] = forms.ChoiceField(
choices=self.TemporaryChoices, # type: ignore
Expand Down
Loading