From 7db1706f4cc22dfaaace2c71f9dc53f841dbfe71 Mon Sep 17 00:00:00 2001
From: Andy Mitchell <326561+Themitchell@users.noreply.github.com>
Date: Wed, 22 Oct 2025 09:39:42 +0100
Subject: [PATCH 1/9] Move IntegerField to nhsuk_forms module and add tests
---
lung_cancer_screening/core/form_fields.py | 65 +-----
.../core/tests/unit/not_test_form_fields.py | 195 ------------------
.../{core/utils => nhsuk_forms}/__init__.py | 0
.../nhsuk_forms/integer_field.py | 30 +++
.../nhsuk_forms/tests/__init__.py | 0
.../nhsuk_forms/tests/unit/__init__.py | 0
.../tests/unit/test_integer_field.py | 21 ++
.../nhsuk_forms/utils/__init__.py | 0
.../utils/date_formatting.py | 0
lung_cancer_screening/settings.py | 1 +
10 files changed, 54 insertions(+), 258 deletions(-)
rename lung_cancer_screening/{core/utils => nhsuk_forms}/__init__.py (100%)
create mode 100644 lung_cancer_screening/nhsuk_forms/integer_field.py
create mode 100644 lung_cancer_screening/nhsuk_forms/tests/__init__.py
create mode 100644 lung_cancer_screening/nhsuk_forms/tests/unit/__init__.py
create mode 100644 lung_cancer_screening/nhsuk_forms/tests/unit/test_integer_field.py
create mode 100644 lung_cancer_screening/nhsuk_forms/utils/__init__.py
rename lung_cancer_screening/{core => nhsuk_forms}/utils/date_formatting.py (100%)
diff --git a/lung_cancer_screening/core/form_fields.py b/lung_cancer_screening/core/form_fields.py
index 2ff0dfc1..b80fbe1f 100644
--- a/lung_cancer_screening/core/form_fields.py
+++ b/lung_cancer_screening/core/form_fields.py
@@ -6,8 +6,8 @@
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _
-from .utils.date_formatting import format_date
-
+from lung_cancer_screening.nhsuk_forms.utils.date_formatting import format_date
+from lung_cancer_screening.nhsuk_forms.integer_field import IntegerField
class SplitDateWidget(widgets.MultiWidget):
"""
@@ -146,67 +146,6 @@ def widget_attrs(self, widget):
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
-
-
-class IntegerField(forms.IntegerField):
- def __init__(
- self,
- *args,
- hint=None,
- label_classes=None,
- classes=None,
- **kwargs,
- ):
- 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 min/max/step attributes.
- attrs.pop("min", None)
- attrs.pop("max", None)
- attrs.pop("step", None)
-
- return attrs
-
class DecimalField(forms.DecimalField):
def __init__(
self,
diff --git a/lung_cancer_screening/core/tests/unit/not_test_form_fields.py b/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
index f36b0040..b0aa022f 100644
--- a/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
+++ b/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
@@ -237,201 +237,6 @@ class TestForm(Form):
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(),
- """
-
-
-
- """,
- )
-
- def test_renders_nhs_input_with_visually_hidden_label(self, form_class):
- assertHTMLEqual(
- form_class()["field_with_visually_hidden_label"].as_field_group(),
- """
-
-
-
- """,
- )
-
- def test_renders_nhs_input_with_hint(self, form_class):
- assertHTMLEqual(
- form_class()["field_with_hint"].as_field_group(),
- """
-
- """,
- )
-
- def test_renders_nhs_input_with_classes(self, form_class):
- assertHTMLEqual(
- form_class()["field_with_classes"].as_field_group(),
- """
-
-
-
-
- """,
- )
-
- def test_renders_nhs_input_with_extra_attrs(self, form_class):
- assertHTMLEqual(
- form_class()["field_with_extra_attrs"].as_field_group(),
- """
-
-
-
-
- """,
- )
-
- def test_bound_value_reflected_in_html_value(self, form_class):
- assertHTMLEqual(
- form_class({"field": "othervalue"})["field"].as_field_group(),
- """
-
-
-
- """,
- )
-
- def test_invalid_value_renders_validation_error(self, form_class):
- assertHTMLEqual(
- form_class({"field": "reallylongvalue"})["field"].as_field_group(),
- """
-
-
-
- Error: Ensure this value has at most 10 characters (it has 15).
-
-
- """,
- )
-
- def test_telinput_renders_input_with_type_tel(self, form_class):
- assertHTMLEqual(
- form_class()["telephone_field"].as_field_group(),
- """
-
-
-
- """,
- )
-
- def test_textarea_renders_textarea(self, form_class):
- assertHTMLEqual(
- form_class()["textfield"].as_field_group(),
- """
-
-
-
-
- """,
- )
-
- def test_textarea_class_renders_textarea(self, form_class):
- assertHTMLEqual(
- form_class()["textfield_simple"].as_field_group(),
- """
-
-
-
-
- """,
- )
-
-
-class TestIntegerField:
- @pytest.fixture
- def form_class(self):
- class TestForm(Form):
- field = IntegerField(label="Abc", initial=1, max_value=10)
-
- return TestForm
-
- def test_renders_nhs_input(self, form_class):
- assertHTMLEqual(
- form_class()["field"].as_field_group(),
- """
-
-
-
- """,
- )
-
-
class TestChoiceField:
@pytest.fixture
def form_class(self):
diff --git a/lung_cancer_screening/core/utils/__init__.py b/lung_cancer_screening/nhsuk_forms/__init__.py
similarity index 100%
rename from lung_cancer_screening/core/utils/__init__.py
rename to lung_cancer_screening/nhsuk_forms/__init__.py
diff --git a/lung_cancer_screening/nhsuk_forms/integer_field.py b/lung_cancer_screening/nhsuk_forms/integer_field.py
new file mode 100644
index 00000000..bed29586
--- /dev/null
+++ b/lung_cancer_screening/nhsuk_forms/integer_field.py
@@ -0,0 +1,30 @@
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+
+class IntegerField(forms.IntegerField):
+ def __init__(
+ self,
+ *args,
+ hint=None,
+ label_classes=None,
+ classes=None,
+ **kwargs,
+ ):
+ 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 min/max/step attributes.
+ attrs.pop("min", None)
+ attrs.pop("max", None)
+ attrs.pop("step", None)
+
+ return attrs
diff --git a/lung_cancer_screening/nhsuk_forms/tests/__init__.py b/lung_cancer_screening/nhsuk_forms/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lung_cancer_screening/nhsuk_forms/tests/unit/__init__.py b/lung_cancer_screening/nhsuk_forms/tests/unit/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lung_cancer_screening/nhsuk_forms/tests/unit/test_integer_field.py b/lung_cancer_screening/nhsuk_forms/tests/unit/test_integer_field.py
new file mode 100644
index 00000000..795f4a7f
--- /dev/null
+++ b/lung_cancer_screening/nhsuk_forms/tests/unit/test_integer_field.py
@@ -0,0 +1,21 @@
+from django.test import TestCase
+from django.forms import Form
+from ...integer_field import IntegerField
+
+
+class TestForm(Form):
+ field = IntegerField(label="Abc", initial=1, max_value=10)
+
+class TestIntegerField(TestCase):
+ def test_renders_nhs_input(self):
+ self.assertHTMLEqual(
+ TestForm()["field"].as_field_group(),
+ """
+
+
+
+ """,
+ )
+
diff --git a/lung_cancer_screening/nhsuk_forms/utils/__init__.py b/lung_cancer_screening/nhsuk_forms/utils/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lung_cancer_screening/core/utils/date_formatting.py b/lung_cancer_screening/nhsuk_forms/utils/date_formatting.py
similarity index 100%
rename from lung_cancer_screening/core/utils/date_formatting.py
rename to lung_cancer_screening/nhsuk_forms/utils/date_formatting.py
diff --git a/lung_cancer_screening/settings.py b/lung_cancer_screening/settings.py
index 9f367c8e..663f605d 100644
--- a/lung_cancer_screening/settings.py
+++ b/lung_cancer_screening/settings.py
@@ -50,6 +50,7 @@ def list_env(key):
'django.contrib.messages',
'django.contrib.staticfiles',
'lung_cancer_screening.core',
+ 'lung_cancer_screening.nhsuk_forms',
'lung_cancer_screening.questions'
]
From f7401886aadfbdf127f84bb8ffdc2b611828ca98 Mon Sep 17 00:00:00 2001
From: Andy Mitchell <326561+Themitchell@users.noreply.github.com>
Date: Wed, 22 Oct 2025 09:58:38 +0100
Subject: [PATCH 2/9] Move SplitDateWidget to nhsuk_forms and add tests
---
lung_cancer_screening/core/form_fields.py | 143 +---------
.../core/tests/unit/not_test_form_fields.py | 224 ---------------
.../nhsuk_forms/split_date_field.py | 148 ++++++++++
.../tests/unit/test_split_date_field.py | 261 ++++++++++++++++++
.../questions/forms/date_of_birth_form.py | 2 +-
5 files changed, 411 insertions(+), 367 deletions(-)
create mode 100644 lung_cancer_screening/nhsuk_forms/split_date_field.py
create mode 100644 lung_cancer_screening/nhsuk_forms/tests/unit/test_split_date_field.py
diff --git a/lung_cancer_screening/core/form_fields.py b/lung_cancer_screening/core/form_fields.py
index b80fbe1f..5a83ad1c 100644
--- a/lung_cancer_screening/core/form_fields.py
+++ b/lung_cancer_screening/core/form_fields.py
@@ -1,151 +1,10 @@
-import datetime
-
from django import forms
-from django.core import validators
-from django.forms import Textarea, ValidationError, widgets
-from django.utils.translation import gettext
+from django.forms import widgets
from django.utils.translation import gettext_lazy as _
from lung_cancer_screening.nhsuk_forms.utils.date_formatting import format_date
from lung_cancer_screening.nhsuk_forms.integer_field import IntegerField
-class SplitDateWidget(widgets.MultiWidget):
- """
- A widget that splits a date into 3 number inputs.
- Adapted from https://github.com/ministryofjustice/django-govuk-forms/blob/master/govuk_forms/widgets.py
- """
-
- def __init__(self, attrs=None):
- date_widgets = (
- widgets.NumberInput(attrs=attrs),
- widgets.NumberInput(attrs=attrs),
- widgets.NumberInput(attrs=attrs),
- )
- super().__init__(date_widgets, attrs)
-
- def decompress(self, value):
- if value:
- return [value.day, value.month, value.year]
- return [None, None, None]
-
- def subwidgets(self, name, value, attrs=None):
- """
- Expose data for each subwidget, so that we can render them separately in the template.
-
- For some reason, as of Django 5.2, `MultiWidget` does not actually override the default
- implementation provided by `Widget`, which means you can't call `form.date.0` `form.date.1`
- to access the individual parts.
- (see https://stackoverflow.com/questions/24866936/render-only-one-part-of-a-multiwidget-in-django)
- """
- context = self.get_context(name, value, attrs)
- for subwidget in context["widget"]["subwidgets"]:
- yield subwidget
-
-
-class SplitHiddenDateWidget(SplitDateWidget):
- """
- A widget that splits a date into 3 number inputs (hidden variant)
- Adapted from https://github.com/ministryofjustice/django-govuk-forms/blob/master/govuk_forms/widgets.py
- """
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- for widget in self.widgets:
- widget.input_type = "hidden"
-
-
-class SplitDateField(forms.MultiValueField):
- """
- A form field that can be rendered as 3 inputs using the dateInput component in the design system.
- Adapted from https://github.com/ministryofjustice/django-govuk-forms/blob/master/govuk_forms/fields.py
- """
-
- widget = SplitDateWidget
- hidden_widget = SplitHiddenDateWidget
- default_error_messages = {"invalid": _("Enter a valid date.")}
-
- def __init__(self, *args, **kwargs):
- max_value = kwargs.pop("max_value", datetime.date.today())
- min_value = kwargs.pop("min_value", datetime.date(1900, 1, 1))
- self.hint = kwargs.pop("hint", None)
-
- day_bounds_error = gettext("Day should be between 1 and 31.")
- month_bounds_error = gettext("Month should be between 1 and 12.")
- year_bounds_error = gettext(
- "Year should be between %(min_year)s and %(max_year)s."
- ) % {"min_year": min_value.year, "max_year": max_value.year}
-
- day_kwargs = {
- "min_value": 1,
- "max_value": 31,
- "error_messages": {
- "min_value": day_bounds_error,
- "max_value": day_bounds_error,
- "invalid": gettext("Enter day as a number."),
- },
- }
- month_kwargs = {
- "min_value": 1,
- "max_value": 12,
- "error_messages": {
- "min_value": month_bounds_error,
- "max_value": month_bounds_error,
- "invalid": gettext("Enter month as a number."),
- },
- }
- year_kwargs = {
- "min_value": min_value.year,
- "max_value": max_value.year,
- "error_messages": {
- "min_value": year_bounds_error,
- "max_value": year_bounds_error,
- "invalid": gettext("Enter year as a number."),
- },
- }
-
- self.fields = [
- IntegerField(**day_kwargs),
- IntegerField(**month_kwargs),
- IntegerField(**year_kwargs),
- ]
-
- kwargs["template_name"] = "forms/date-input.jinja"
-
- super().__init__(self.fields, *args, **kwargs)
-
- self.validators.append(
- validators.MinValueValidator(
- min_value, f"Enter a date after {format_date(min_value)}"
- )
- )
- self.validators.append(
- validators.MaxValueValidator(
- max_value, f"Enter a date before {format_date(max_value)}"
- )
- )
-
- def compress(self, data_list):
- if data_list:
- try:
- if any(item in self.empty_values for item in data_list):
- raise ValueError
- return datetime.date(data_list[2], data_list[1], data_list[0])
- except ValueError:
- raise ValidationError(
- self.error_messages["invalid"], code="invalid")
- return None
-
- def widget_attrs(self, widget):
- attrs = super().widget_attrs(widget)
- if not isinstance(widget, SplitDateWidget):
- return attrs
- for subfield, subwidget in zip(self.fields, widget.widgets):
- if subfield.min_value is not None:
- subwidget.attrs["min"] = subfield.min_value
- if subfield.max_value is not None:
- subwidget.attrs["max"] = subfield.max_value
- return attrs
-
class DecimalField(forms.DecimalField):
def __init__(
self,
diff --git a/lung_cancer_screening/core/tests/unit/not_test_form_fields.py b/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
index b0aa022f..eb90b1f1 100644
--- a/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
+++ b/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
@@ -1,242 +1,18 @@
-import datetime
-
import pytest
-from django.core.exceptions import ValidationError
from django.forms import Form
from django.forms.widgets import (
CheckboxSelectMultiple,
Select,
- TelInput,
- Textarea,
- TextInput,
)
from pytest_django.asserts import assertHTMLEqual
from ..form_fields import (
CharField,
ChoiceField,
- IntegerField,
MultipleChoiceField,
- SplitDateField,
TypedChoiceField,
)
-
-class TestSplitDateField:
- def test_clean(self):
- f = SplitDateField(max_value=datetime.date(2026, 6, 30))
-
- assert f.clean([1, 12, 2025]) == datetime.date(2025, 12, 1)
-
- with pytest.raises(ValidationError, match="This field is required."):
- f.clean(None)
-
- with pytest.raises(ValidationError, match="This field is required."):
- f.clean("")
-
- with pytest.raises(ValidationError, match="Enter a valid date."):
- f.clean("hello")
-
- with pytest.raises(
- ValidationError,
- match=r"\['Enter day as a number.', 'Enter month as a number.', 'Enter year as a number.'\]",
- ):
- f.clean(["a", "b", "c"])
-
- with pytest.raises(
- ValidationError,
- match=r"\['This field is required.'\]",
- ):
- f.clean(["", "", ""])
-
- with pytest.raises(
- ValidationError,
- match=r"\['Day should be between 1 and 31.', 'Month should be between 1 and 12.', 'Year should be between 1900 and 2026.']",
- ):
- f.clean([0, 13, 1800])
-
- with pytest.raises(
- ValidationError,
- match=r"\['Enter a date before 30 June 2026'\]",
- ):
- f.clean([1, 7, 2026])
-
- def test_has_changed(self):
- f = SplitDateField(max_value=datetime.date(2026, 12, 31))
- assert f.has_changed([1, 12, 2025], [2, 12, 2025])
- assert f.has_changed([1, 12, 2025], [1, 11, 2025])
- assert f.has_changed([1, 12, 2025], [1, 12, 2026])
- assert not f.has_changed([1, 12, 2025], [1, 12, 2025])
-
- def test_default_django_render(self):
- class TestForm(Form):
- date = SplitDateField(max_value=datetime.date(2026, 12, 31))
-
- f = TestForm()
-
- assertHTMLEqual(
- str(f),
- """
- """,
- )
-
- def test_default_django_render_in_bound_form(self):
- class TestForm(Form):
- date = SplitDateField(max_value=datetime.date(2026, 12, 31))
-
- f = TestForm({"date_0": "1", "date_1": "12", "date_2": "2025"})
-
- assertHTMLEqual(
- str(f),
- """
- """,
- )
-
- def test_form_cleaned_data(self):
- class TestForm(Form):
- date = SplitDateField(max_value=datetime.date(2026, 12, 31))
-
- f = TestForm({"date_0": "1", "date_1": "12", "date_2": "2025"})
-
- assert f.is_valid()
- assert f.cleaned_data["date"] == datetime.date(2025, 12, 1)
-
- def test_bound_field_subwidgets(self):
- class TestForm(Form):
- date = SplitDateField(max_value=datetime.date(2026, 12, 31))
-
- f = TestForm({"date_0": "1", "date_1": "12", "date_2": "2025"})
- field = f["date"]
-
- assert len(field.subwidgets) == 3
-
- assert field.subwidgets[0].data == {
- "attrs": {
- "id": "id_date_0",
- "max": 31,
- "min": 1,
- "required": True,
- },
- "is_hidden": False,
- "name": "date_0",
- "required": False,
- "template_name": "django/forms/widgets/number.html",
- "type": "number",
- "value": "1",
- }
-
- assert field.subwidgets[1].data == {
- "attrs": {
- "id": "id_date_1",
- "max": 12,
- "min": 1,
- "required": True,
- },
- "is_hidden": False,
- "name": "date_1",
- "required": False,
- "template_name": "django/forms/widgets/number.html",
- "type": "number",
- "value": "12",
- }
-
- assert field.subwidgets[2].data == {
- "attrs": {
- "id": "id_date_2",
- "max": 2026,
- "min": 1900,
- "required": True,
- },
- "is_hidden": False,
- "name": "date_2",
- "required": False,
- "template_name": "django/forms/widgets/number.html",
- "type": "number",
- "value": "2025",
- }
-
- def test_subfield_errors_on_form(self):
- class TestForm(Form):
- date = SplitDateField(max_value=datetime.date(2026, 12, 31))
-
- f = TestForm({"date_0": "1", "date_1": "12", "date_2": "2027"})
- assert not f.is_valid()
- assert f.errors == {"date": ["Year should be between 1900 and 2026."]}
-
- def test_same_year_but_past_max_value(self):
- class TestForm(Form):
- date = SplitDateField(max_value=datetime.date(2026, 7, 1))
-
- 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 TestChoiceField:
@pytest.fixture
def form_class(self):
diff --git a/lung_cancer_screening/nhsuk_forms/split_date_field.py b/lung_cancer_screening/nhsuk_forms/split_date_field.py
new file mode 100644
index 00000000..17653587
--- /dev/null
+++ b/lung_cancer_screening/nhsuk_forms/split_date_field.py
@@ -0,0 +1,148 @@
+import datetime
+
+from django import forms
+from django.core import validators
+from django.forms import ValidationError, widgets
+from django.utils.translation import gettext
+from django.utils.translation import gettext_lazy as _
+
+from lung_cancer_screening.nhsuk_forms.utils.date_formatting import format_date
+from lung_cancer_screening.nhsuk_forms.integer_field import IntegerField
+
+
+class SplitDateWidget(widgets.MultiWidget):
+ """
+ A widget that splits a date into 3 number inputs.
+ Adapted from https://github.com/ministryofjustice/django-govuk-forms/blob/master/govuk_forms/widgets.py
+ """
+
+ def __init__(self, attrs=None):
+ date_widgets = (
+ widgets.NumberInput(attrs=attrs),
+ widgets.NumberInput(attrs=attrs),
+ widgets.NumberInput(attrs=attrs),
+ )
+ super().__init__(date_widgets, attrs)
+
+ def decompress(self, value):
+ if value:
+ return [value.day, value.month, value.year]
+ return [None, None, None]
+
+ def subwidgets(self, name, value, attrs=None):
+ """
+ Expose data for each subwidget, so that we can render them separately in the template.
+
+ For some reason, as of Django 5.2, `MultiWidget` does not actually override the default
+ implementation provided by `Widget`, which means you can't call `form.date.0` `form.date.1`
+ to access the individual parts.
+ (see https://stackoverflow.com/questions/24866936/render-only-one-part-of-a-multiwidget-in-django)
+ """
+ context = self.get_context(name, value, attrs)
+ for subwidget in context["widget"]["subwidgets"]:
+ yield subwidget
+
+
+class SplitHiddenDateWidget(SplitDateWidget):
+ """
+ A widget that splits a date into 3 number inputs (hidden variant)
+ Adapted from https://github.com/ministryofjustice/django-govuk-forms/blob/master/govuk_forms/widgets.py
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ for widget in self.widgets:
+ widget.input_type = "hidden"
+
+
+class SplitDateField(forms.MultiValueField):
+ """
+ A form field that can be rendered as 3 inputs using the dateInput component in the design system.
+ Adapted from https://github.com/ministryofjustice/django-govuk-forms/blob/master/govuk_forms/fields.py
+ """
+
+ widget = SplitDateWidget
+ hidden_widget = SplitHiddenDateWidget
+ default_error_messages = {"invalid": _("Enter a valid date.")}
+
+ def __init__(self, *args, **kwargs):
+ max_value = kwargs.pop("max_value", datetime.date.today())
+ min_value = kwargs.pop("min_value", datetime.date(1900, 1, 1))
+ self.hint = kwargs.pop("hint", None)
+
+ day_bounds_error = gettext("Day should be between 1 and 31.")
+ month_bounds_error = gettext("Month should be between 1 and 12.")
+ year_bounds_error = gettext(
+ "Year should be between %(min_year)s and %(max_year)s."
+ ) % {"min_year": min_value.year, "max_year": max_value.year}
+
+ day_kwargs = {
+ "min_value": 1,
+ "max_value": 31,
+ "error_messages": {
+ "min_value": day_bounds_error,
+ "max_value": day_bounds_error,
+ "invalid": gettext("Enter day as a number."),
+ },
+ }
+ month_kwargs = {
+ "min_value": 1,
+ "max_value": 12,
+ "error_messages": {
+ "min_value": month_bounds_error,
+ "max_value": month_bounds_error,
+ "invalid": gettext("Enter month as a number."),
+ },
+ }
+ year_kwargs = {
+ "min_value": min_value.year,
+ "max_value": max_value.year,
+ "error_messages": {
+ "min_value": year_bounds_error,
+ "max_value": year_bounds_error,
+ "invalid": gettext("Enter year as a number."),
+ },
+ }
+
+ self.fields = [
+ IntegerField(**day_kwargs),
+ IntegerField(**month_kwargs),
+ IntegerField(**year_kwargs),
+ ]
+
+ kwargs["template_name"] = "forms/date-input.jinja"
+
+ super().__init__(self.fields, *args, **kwargs)
+
+ self.validators.append(
+ validators.MinValueValidator(
+ min_value, f"Enter a date after {format_date(min_value)}."
+ )
+ )
+ self.validators.append(
+ validators.MaxValueValidator(
+ max_value, f"Enter a date before {format_date(max_value)}."
+ )
+ )
+
+ def compress(self, data_list):
+ if data_list:
+ try:
+ if any(item in self.empty_values for item in data_list):
+ raise ValueError
+ return datetime.date(data_list[2], data_list[1], data_list[0])
+ except ValueError:
+ raise ValidationError(
+ self.error_messages["invalid"], code="invalid")
+ return None
+
+ def widget_attrs(self, widget):
+ attrs = super().widget_attrs(widget)
+ if not isinstance(widget, SplitDateWidget):
+ return attrs
+ for subfield, subwidget in zip(self.fields, widget.widgets):
+ if subfield.min_value is not None:
+ subwidget.attrs["min"] = subfield.min_value
+ if subfield.max_value is not None:
+ subwidget.attrs["max"] = subfield.max_value
+ return attrs
diff --git a/lung_cancer_screening/nhsuk_forms/tests/unit/test_split_date_field.py b/lung_cancer_screening/nhsuk_forms/tests/unit/test_split_date_field.py
new file mode 100644
index 00000000..9c0ea2f9
--- /dev/null
+++ b/lung_cancer_screening/nhsuk_forms/tests/unit/test_split_date_field.py
@@ -0,0 +1,261 @@
+import datetime
+
+from django.test import TestCase
+from django.core.exceptions import ValidationError
+from django.forms import Form
+
+from ...split_date_field import SplitDateField
+
+class TestSplitDateField(TestCase):
+ def test_clean(self):
+ f = SplitDateField(max_value=datetime.date(2026, 6, 30))
+
+ assert f.clean([1, 12, 2025]) == datetime.date(2025, 12, 1)
+
+ with self.assertRaises(ValidationError) as context:
+ f.clean(None)
+
+ self.assertEqual(
+ context.exception.messages[0],
+ "This field is required."
+ )
+
+ with self.assertRaises(ValidationError) as context:
+ f.clean("")
+
+ self.assertEqual(
+ context.exception.messages[0],
+ "This field is required."
+ )
+
+ with self.assertRaises(ValidationError) as context:
+ f.clean("hello")
+
+ self.assertEqual(
+ context.exception.messages[0],
+ "Enter a valid date."
+ )
+
+ with self.assertRaises(ValidationError) as context:
+ f.clean(["a", "b", "c"])
+
+ self.assertIn(
+ "Enter day as a number.",
+ context.exception.messages
+ )
+ self.assertIn(
+ "Enter month as a number.",
+ context.exception.messages
+ )
+ self.assertIn(
+ "Enter year as a number.",
+ context.exception.messages
+ )
+
+ with self.assertRaises(ValidationError) as context:
+ f.clean(["", "", ""])
+
+ self.assertEqual(
+ context.exception.messages[0],
+ "This field is required."
+ )
+
+ with self.assertRaises(ValidationError) as context:
+ f.clean([0, 13, 1800])
+
+ self.assertIn(
+ "Day should be between 1 and 31.",
+ context.exception.messages
+ )
+ self.assertIn(
+ "Month should be between 1 and 12.",
+ context.exception.messages
+ )
+ self.assertIn(
+ "Year should be between 1900 and 2026.",
+ context.exception.messages
+ )
+
+ with self.assertRaises(ValidationError) as context:
+ f.clean([1, 7, 2026])
+
+ self.assertIn(
+ "Enter a date before 30 June 2026.",
+ context.exception.messages
+ )
+
+ def test_has_changed(self):
+ f = SplitDateField(max_value=datetime.date(2026, 12, 31))
+ assert f.has_changed([1, 12, 2025], [2, 12, 2025])
+ assert f.has_changed([1, 12, 2025], [1, 11, 2025])
+ assert f.has_changed([1, 12, 2025], [1, 12, 2026])
+ assert not f.has_changed([1, 12, 2025], [1, 12, 2025])
+
+ def test_default_django_render(self):
+ class TestForm(Form):
+ date = SplitDateField(max_value=datetime.date(2026, 12, 31))
+
+ f = TestForm()
+
+ self.assertHTMLEqual(
+ str(f),
+ """
+ """,
+ )
+
+ def test_default_django_render_in_bound_form(self):
+ class TestForm(Form):
+ date = SplitDateField(max_value=datetime.date(2026, 12, 31))
+
+ f = TestForm({"date_0": "1", "date_1": "12", "date_2": "2025"})
+
+ self.assertHTMLEqual(
+ str(f),
+ """
+ """,
+ )
+
+ def test_form_cleaned_data(self):
+ class TestForm(Form):
+ date = SplitDateField(max_value=datetime.date(2026, 12, 31))
+
+ f = TestForm({"date_0": "1", "date_1": "12", "date_2": "2025"})
+
+ assert f.is_valid()
+ assert f.cleaned_data["date"] == datetime.date(2025, 12, 1)
+
+ def test_bound_field_subwidgets(self):
+ class TestForm(Form):
+ date = SplitDateField(max_value=datetime.date(2026, 12, 31))
+
+ f = TestForm({"date_0": "1", "date_1": "12", "date_2": "2025"})
+ field = f["date"]
+
+ assert len(field.subwidgets) == 3
+
+ assert field.subwidgets[0].data == {
+ "attrs": {
+ "id": "id_date_0",
+ "max": 31,
+ "min": 1,
+ "required": True,
+ },
+ "is_hidden": False,
+ "name": "date_0",
+ "required": False,
+ "template_name": "django/forms/widgets/number.html",
+ "type": "number",
+ "value": "1",
+ }
+
+ assert field.subwidgets[1].data == {
+ "attrs": {
+ "id": "id_date_1",
+ "max": 12,
+ "min": 1,
+ "required": True,
+ },
+ "is_hidden": False,
+ "name": "date_1",
+ "required": False,
+ "template_name": "django/forms/widgets/number.html",
+ "type": "number",
+ "value": "12",
+ }
+
+ assert field.subwidgets[2].data == {
+ "attrs": {
+ "id": "id_date_2",
+ "max": 2026,
+ "min": 1900,
+ "required": True,
+ },
+ "is_hidden": False,
+ "name": "date_2",
+ "required": False,
+ "template_name": "django/forms/widgets/number.html",
+ "type": "number",
+ "value": "2025",
+ }
+
+ def test_subfield_errors_on_form(self):
+ class TestForm(Form):
+ date = SplitDateField(max_value=datetime.date(2026, 12, 31))
+
+ f = TestForm({"date_0": "1", "date_1": "12", "date_2": "2027"})
+ assert not f.is_valid()
+ assert f.errors == {"date": ["Year should be between 1900 and 2026."]}
+
+ def test_same_year_but_past_max_value(self):
+ class TestForm(Form):
+ date = SplitDateField(max_value=datetime.date(2026, 7, 1))
+
+ 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."]}
diff --git a/lung_cancer_screening/questions/forms/date_of_birth_form.py b/lung_cancer_screening/questions/forms/date_of_birth_form.py
index affa0951..3556ab4c 100644
--- a/lung_cancer_screening/questions/forms/date_of_birth_form.py
+++ b/lung_cancer_screening/questions/forms/date_of_birth_form.py
@@ -2,7 +2,7 @@
from datetime import date
from ..models.response_set import ResponseSet
-from lung_cancer_screening.core.form_fields import SplitDateField
+from ...nhsuk_forms.split_date_field import SplitDateField
class DateOfBirthForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
From 27c1e21788b463b40388f7f7c3812a7d85a5ab76 Mon Sep 17 00:00:00 2001
From: Andy Mitchell <326561+Themitchell@users.noreply.github.com>
Date: Wed, 22 Oct 2025 11:17:26 +0100
Subject: [PATCH 3/9] Move TypedFormField to nhsuk_forms and add tests
---
lung_cancer_screening/core/form_fields.py | 40 -------
.../core/tests/unit/not_test_form_fields.py | 110 ------------------
.../tests/unit/test_typed_choice_field.py | 79 +++++++++++++
.../nhsuk_forms/typed_choice_field.py | 44 +++++++
.../forms/have_you_ever_smoked_form.py | 2 +-
5 files changed, 124 insertions(+), 151 deletions(-)
create mode 100644 lung_cancer_screening/nhsuk_forms/tests/unit/test_typed_choice_field.py
create mode 100644 lung_cancer_screening/nhsuk_forms/typed_choice_field.py
diff --git a/lung_cancer_screening/core/form_fields.py b/lung_cancer_screening/core/form_fields.py
index 5a83ad1c..7b22c89b 100644
--- a/lung_cancer_screening/core/form_fields.py
+++ b/lung_cancer_screening/core/form_fields.py
@@ -103,46 +103,6 @@ def _template_name(widget):
return "forms/select.jinja"
-class TypedChoiceField(forms.TypedChoiceField):
- """
- A TypedChoiceField that renders using NHS.UK design system radios/select
- components.
- """
-
- widget = widgets.RadioSelect
- bound_field_class = BoundChoiceField
-
- def __init__(
- self,
- *args,
- hint=None,
- label_classes=None,
- classes=None,
- **kwargs,
- ):
- kwargs["template_name"] = TypedChoiceField._template_name(
- kwargs.get("widget", self.widget)
- )
-
- self.hint = hint
- self.classes = classes
- self.label_classes = label_classes
-
- super().__init__(*args, **kwargs)
-
- @staticmethod
- def _template_name(widget):
- if (isinstance(widget, type) and widget is widgets.RadioSelect) or isinstance(
- widget, widgets.RadioSelect
- ):
- return "forms/radios.jinja"
- elif (isinstance(widget, type) and widget is widgets.Select) or isinstance(
- widget, widgets.Select
- ):
- return "forms/select.jinja"
-
-
-
class MultipleChoiceField(forms.MultipleChoiceField):
"""
A MultipleChoiceField that renders using the NHS.UK design system checkboxes
diff --git a/lung_cancer_screening/core/tests/unit/not_test_form_fields.py b/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
index eb90b1f1..449441da 100644
--- a/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
+++ b/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
@@ -123,116 +123,6 @@ def test_adding_dividers_via_boundfield(self, form_class):
assert bound_field.get_divider_after("a") == "or"
-class TestTypedChoiceField:
- @pytest.fixture
- def form_class(self):
- class TestForm(Form):
- field = TypedChoiceField(
- label="Abc",
- label_classes="app-abc",
- choices=(("a", "A"), ("b", "B")),
- hint="Pick either one",
- )
- select_field = TypedChoiceField(
- label="Select",
- label_classes="app-select",
- choices=(("a", "A"), ("b", "B")),
- hint="Pick either one",
- widget=Select,
- )
- details = CharField(label="Abc", initial="")
-
- return TestForm
-
- def test_renders_nhs_radios(self, form_class):
- assertHTMLEqual(
- form_class()["field"].as_field_group(),
- """
-
- """,
- )
-
- def test_renders_radios_with_conditional_html(self, form_class):
- form = form_class()
- form["field"].add_conditional_html("b", "Hello
")
-
- assertHTMLEqual(
- form["field"].as_field_group(),
- """
-
- """,
- )
-
- def test_renders_nhs_select(self, form_class):
- assertHTMLEqual(
- form_class()["select_field"].as_field_group(),
- """
-
- """,
- )
-
- def test_renders_select_with_conditional_html(self, form_class):
- form = form_class()
-
- with pytest.raises(ValueError):
- form["select_field"].add_conditional_html("b", "Hello
")
-
- def test_adding_dividers_via_boundfield(self, form_class):
- bound_field = form_class()["field"]
- bound_field.add_divider_after("a", "or")
- assert bound_field.get_divider_after("a") == "or"
-
-
class TestMultipleChoiceField:
@pytest.fixture
def form_class(self):
diff --git a/lung_cancer_screening/nhsuk_forms/tests/unit/test_typed_choice_field.py b/lung_cancer_screening/nhsuk_forms/tests/unit/test_typed_choice_field.py
new file mode 100644
index 00000000..433eb1be
--- /dev/null
+++ b/lung_cancer_screening/nhsuk_forms/tests/unit/test_typed_choice_field.py
@@ -0,0 +1,79 @@
+from django.test import TestCase
+from django.forms import Form
+
+from ...typed_choice_field import TypedChoiceField
+
+
+class TestForm(Form):
+ field = TypedChoiceField(
+ label="Abc",
+ label_classes="app-abc",
+ choices=(("a", "A"), ("b", "B")),
+ hint="Pick either one",
+ )
+
+class TestTypedChoiceField(TestCase):
+ def test_renders_nhs_radios(self):
+ self.assertHTMLEqual(
+ TestForm()["field"].as_field_group(),
+ """
+
+ """,
+ )
+
+ def test_renders_radios_with_conditional_html(self):
+ form = TestForm()
+ form["field"].add_conditional_html("b", "Hello
")
+
+ self.assertHTMLEqual(
+ form["field"].as_field_group(),
+ """
+
+ """,
+ )
+
+ def test_adding_dividers_via_boundfield(self):
+ bound_field = TestForm()["field"]
+ bound_field.add_divider_after("a", "or")
+ assert bound_field.get_divider_after("a") == "or"
diff --git a/lung_cancer_screening/nhsuk_forms/typed_choice_field.py b/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
new file mode 100644
index 00000000..fe7dd3aa
--- /dev/null
+++ b/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
@@ -0,0 +1,44 @@
+
+from django import forms
+from django.forms import widgets
+from django.utils.translation import gettext_lazy as _
+
+from lung_cancer_screening.core.form_fields import BoundChoiceField
+
+class TypedChoiceField(forms.TypedChoiceField):
+ """
+ A TypedChoiceField that renders using NHS.UK design system radios/select
+ components.
+ """
+
+ widget = widgets.RadioSelect
+ bound_field_class = BoundChoiceField
+
+ def __init__(
+ self,
+ *args,
+ hint=None,
+ label_classes=None,
+ classes=None,
+ **kwargs,
+ ):
+ kwargs["template_name"] = TypedChoiceField._template_name(
+ kwargs.get("widget", self.widget)
+ )
+
+ self.hint = hint
+ self.classes = classes
+ self.label_classes = label_classes
+
+ super().__init__(*args, **kwargs)
+
+ @staticmethod
+ def _template_name(widget):
+ if (isinstance(widget, type) and widget is widgets.RadioSelect) or isinstance(
+ widget, widgets.RadioSelect
+ ):
+ return "forms/radios.jinja"
+ elif (isinstance(widget, type) and widget is widgets.Select) or isinstance(
+ widget, widgets.Select
+ ):
+ return "forms/select.jinja"
diff --git a/lung_cancer_screening/questions/forms/have_you_ever_smoked_form.py b/lung_cancer_screening/questions/forms/have_you_ever_smoked_form.py
index 8bb008cf..e0ac4bdc 100644
--- a/lung_cancer_screening/questions/forms/have_you_ever_smoked_form.py
+++ b/lung_cancer_screening/questions/forms/have_you_ever_smoked_form.py
@@ -1,6 +1,6 @@
from django import forms
-from lung_cancer_screening.core.form_fields import TypedChoiceField
+from ...nhsuk_forms.typed_choice_field import TypedChoiceField
from ..models.response_set import ResponseSet, HaveYouEverSmokedValues
class HaveYouEverSmokedForm(forms.ModelForm):
From 86cc03cc0553fc1860664d9893493b7eba9dd057 Mon Sep 17 00:00:00 2001
From: Andy Mitchell <326561+Themitchell@users.noreply.github.com>
Date: Wed, 22 Oct 2025 11:27:11 +0100
Subject: [PATCH 4/9] Move ChoiceField to nhsuk_forms module and add tests
---
.../core/tests/unit/not_test_form_fields.py | 114 ------------------
.../nhsuk_forms/choice_field.py | 36 ++++++
.../tests/unit/test_choice_field.py | 78 ++++++++++++
.../nhsuk_forms/typed_choice_field.py | 9 +-
4 files changed, 115 insertions(+), 122 deletions(-)
create mode 100644 lung_cancer_screening/nhsuk_forms/choice_field.py
create mode 100644 lung_cancer_screening/nhsuk_forms/tests/unit/test_choice_field.py
diff --git a/lung_cancer_screening/core/tests/unit/not_test_form_fields.py b/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
index 449441da..0c032662 100644
--- a/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
+++ b/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
@@ -2,127 +2,13 @@
from django.forms import Form
from django.forms.widgets import (
CheckboxSelectMultiple,
- Select,
)
from pytest_django.asserts import assertHTMLEqual
from ..form_fields import (
CharField,
- ChoiceField,
MultipleChoiceField,
- TypedChoiceField,
)
-
-class TestChoiceField:
- @pytest.fixture
- def form_class(self):
- class TestForm(Form):
- field = ChoiceField(
- label="Abc",
- label_classes="app-abc",
- choices=(("a", "A"), ("b", "B")),
- hint="Pick either one",
- )
- select_field = ChoiceField(
- label="Select",
- label_classes="app-select",
- choices=(("a", "A"), ("b", "B")),
- hint="Pick either one",
- widget=Select,
- )
- details = CharField(label="Abc", initial="")
-
- return TestForm
-
- def test_renders_nhs_radios(self, form_class):
- assertHTMLEqual(
- form_class()["field"].as_field_group(),
- """
-
- """,
- )
-
- def test_renders_radios_with_conditional_html(self, form_class):
- form = form_class()
- form["field"].add_conditional_html("b", "Hello
")
-
- assertHTMLEqual(
- form["field"].as_field_group(),
- """
-
- """,
- )
-
- def test_renders_nhs_select(self, form_class):
- assertHTMLEqual(
- form_class()["select_field"].as_field_group(),
- """
-
- """,
- )
-
- def test_renders_select_with_conditional_html(self, form_class):
- form = form_class()
-
- with pytest.raises(ValueError):
- form["select_field"].add_conditional_html("b", "Hello
")
-
- def test_adding_dividers_via_boundfield(self, form_class):
- bound_field = form_class()["field"]
- bound_field.add_divider_after("a", "or")
- assert bound_field.get_divider_after("a") == "or"
-
-
class TestMultipleChoiceField:
@pytest.fixture
def form_class(self):
diff --git a/lung_cancer_screening/nhsuk_forms/choice_field.py b/lung_cancer_screening/nhsuk_forms/choice_field.py
new file mode 100644
index 00000000..b306b965
--- /dev/null
+++ b/lung_cancer_screening/nhsuk_forms/choice_field.py
@@ -0,0 +1,36 @@
+from django import forms
+from django.forms import widgets
+from django.utils.translation import gettext_lazy as _
+
+from lung_cancer_screening.core.form_fields import BoundChoiceField
+
+class ChoiceField(forms.ChoiceField):
+ """
+ A ChoiceField that renders using NHS.UK design system radios/select
+ components.
+ """
+
+ widget = widgets.RadioSelect
+ bound_field_class = BoundChoiceField
+
+ def __init__(
+ self,
+ *args,
+ hint=None,
+ label_classes=None,
+ classes=None,
+ **kwargs,
+ ):
+ kwargs["template_name"] = ChoiceField._template_name(
+ kwargs.get("widget", self.widget)
+ )
+
+ self.hint = hint
+ self.classes = classes
+ self.label_classes = label_classes
+
+ super().__init__(*args, **kwargs)
+
+ @staticmethod
+ def _template_name(widget):
+ return "forms/radios.jinja"
diff --git a/lung_cancer_screening/nhsuk_forms/tests/unit/test_choice_field.py b/lung_cancer_screening/nhsuk_forms/tests/unit/test_choice_field.py
new file mode 100644
index 00000000..d8ae7f62
--- /dev/null
+++ b/lung_cancer_screening/nhsuk_forms/tests/unit/test_choice_field.py
@@ -0,0 +1,78 @@
+from django.test import TestCase
+from django.forms import Form
+
+from ...choice_field import ChoiceField
+
+class TestForm(Form):
+ field = ChoiceField(
+ label="Abc",
+ label_classes="app-abc",
+ choices=(("a", "A"), ("b", "B")),
+ hint="Pick either one",
+ )
+
+class TestChoiceField(TestCase):
+ def test_renders_nhs_radios(self):
+ self.assertHTMLEqual(
+ TestForm()["field"].as_field_group(),
+ """
+
+ """,
+ )
+
+ def test_renders_radios_with_conditional_html(self):
+ form = TestForm()
+ form["field"].add_conditional_html("b", "Hello
")
+
+ self.assertHTMLEqual(
+ form["field"].as_field_group(),
+ """
+
+ """,
+ )
+
+ def test_adding_dividers_via_boundfield(self):
+ bound_field = TestForm()["field"]
+ bound_field.add_divider_after("a", "or")
+ assert bound_field.get_divider_after("a") == "or"
diff --git a/lung_cancer_screening/nhsuk_forms/typed_choice_field.py b/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
index fe7dd3aa..11907980 100644
--- a/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
+++ b/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
@@ -34,11 +34,4 @@ def __init__(
@staticmethod
def _template_name(widget):
- if (isinstance(widget, type) and widget is widgets.RadioSelect) or isinstance(
- widget, widgets.RadioSelect
- ):
- return "forms/radios.jinja"
- elif (isinstance(widget, type) and widget is widgets.Select) or isinstance(
- widget, widgets.Select
- ):
- return "forms/select.jinja"
+ return "forms/radios.jinja"
From 844fc011181f906ebd2c8ec65ba78c3c873fd047 Mon Sep 17 00:00:00 2001
From: Andy Mitchell <326561+Themitchell@users.noreply.github.com>
Date: Wed, 22 Oct 2025 11:32:33 +0100
Subject: [PATCH 5/9] Move BoundFormField to nhsuk_forms
---
lung_cancer_screening/core/form_fields.py | 96 -------------------
.../core/tests/unit/not_test_form_fields.py | 85 ----------------
.../nhsuk_forms/bound_choice_field.py | 34 +++++++
.../nhsuk_forms/choice_field.py | 2 +-
.../nhsuk_forms/typed_choice_field.py | 2 +-
5 files changed, 36 insertions(+), 183 deletions(-)
delete mode 100644 lung_cancer_screening/core/tests/unit/not_test_form_fields.py
create mode 100644 lung_cancer_screening/nhsuk_forms/bound_choice_field.py
diff --git a/lung_cancer_screening/core/form_fields.py b/lung_cancer_screening/core/form_fields.py
index 7b22c89b..14efc55a 100644
--- a/lung_cancer_screening/core/form_fields.py
+++ b/lung_cancer_screening/core/form_fields.py
@@ -33,102 +33,6 @@ def widget_attrs(self, widget):
return attrs
-class BoundChoiceField(forms.BoundField):
- """
- Specialisation of BoundField that can deal with conditionally shown fields,
- and divider content between choices.
- This can be used to render a set of radios or checkboxes with text boxes to capture
- more details.
- """
-
- def __init__(self, form: forms.Form, field: "ChoiceField", name: str):
- super().__init__(form, field, name)
-
- self._conditional_html = {}
- self.dividers = {}
-
- def add_conditional_html(self, value, html):
- if isinstance(self.field.widget, widgets.Select):
- raise ValueError(
- "select component does not support conditional fields")
-
- self._conditional_html[value] = html
-
- def conditional_html(self, value):
- return self._conditional_html.get(value)
-
- def add_divider_after(self, previous, divider):
- self.dividers[previous] = divider
-
- def get_divider_after(self, previous):
- return self.dividers.get(previous)
-
-
-class ChoiceField(forms.ChoiceField):
- """
- A ChoiceField that renders using NHS.UK design system radios/select
- components.
- """
-
- widget = widgets.RadioSelect
- bound_field_class = BoundChoiceField
-
- def __init__(
- self,
- *args,
- hint=None,
- label_classes=None,
- classes=None,
- **kwargs,
- ):
- kwargs["template_name"] = ChoiceField._template_name(
- kwargs.get("widget", self.widget)
- )
-
- self.hint = hint
- self.classes = classes
- self.label_classes = label_classes
-
- super().__init__(*args, **kwargs)
-
- @staticmethod
- def _template_name(widget):
- if (isinstance(widget, type) and widget is widgets.RadioSelect) or isinstance(
- widget, widgets.RadioSelect
- ):
- return "forms/radios.jinja"
- elif (isinstance(widget, type) and widget is widgets.Select) or isinstance(
- widget, widgets.Select
- ):
- return "forms/select.jinja"
-
-
-class MultipleChoiceField(forms.MultipleChoiceField):
- """
- A MultipleChoiceField that renders using the NHS.UK design system checkboxes
- component.
- """
-
- widget = widgets.CheckboxSelectMultiple
- bound_field_class = BoundChoiceField
-
- def __init__(
- self,
- *args,
- hint=None,
- label_classes=None,
- classes=None,
- **kwargs,
- ):
- kwargs["template_name"] = "forms/checkboxes.jinja"
-
- self.hint = hint
- self.classes = classes
- self.label_classes = label_classes
-
- super().__init__(*args, **kwargs)
-
-
class ImperialHeightWidget(widgets.MultiWidget):
"""
A widget that splits height into feet and inches inputs.
diff --git a/lung_cancer_screening/core/tests/unit/not_test_form_fields.py b/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
deleted file mode 100644
index 0c032662..00000000
--- a/lung_cancer_screening/core/tests/unit/not_test_form_fields.py
+++ /dev/null
@@ -1,85 +0,0 @@
-import pytest
-from django.forms import Form
-from django.forms.widgets import (
- CheckboxSelectMultiple,
-)
-from pytest_django.asserts import assertHTMLEqual
-
-from ..form_fields import (
- CharField,
- MultipleChoiceField,
-)
-class TestMultipleChoiceField:
- @pytest.fixture
- def form_class(self):
- class TestForm(Form):
- checkbox_field = MultipleChoiceField(
- label="Def",
- label_classes="app-def",
- choices=(("a", "A"), ("b", "B")),
- hint="Pick any number",
- widget=CheckboxSelectMultiple,
- )
- details = CharField(label="Abc", initial="")
-
- return TestForm
-
- def test_renders_nhs_checkboxes(self, form_class):
- assertHTMLEqual(
- form_class()["checkbox_field"].as_field_group(),
- """
-
- """,
- )
-
- def test_renders_nhs_checkboxes_with_conditional_html(self, form_class):
- form = form_class()
- form["checkbox_field"].add_conditional_html("b", "Hello
")
-
- assertHTMLEqual(
- form["checkbox_field"].as_field_group(),
- """
-
- """,
- )
diff --git a/lung_cancer_screening/nhsuk_forms/bound_choice_field.py b/lung_cancer_screening/nhsuk_forms/bound_choice_field.py
new file mode 100644
index 00000000..77f0dfb9
--- /dev/null
+++ b/lung_cancer_screening/nhsuk_forms/bound_choice_field.py
@@ -0,0 +1,34 @@
+from django import forms
+from django.forms import widgets
+from django.utils.translation import gettext_lazy as _
+
+
+class BoundChoiceField(forms.BoundField):
+ """
+ Specialisation of BoundField that can deal with conditionally shown fields,
+ and divider content between choices.
+ This can be used to render a set of radios or checkboxes with text boxes to capture
+ more details.
+ """
+
+ def __init__(self, form: forms.Form, field: "ChoiceField", name: str):
+ super().__init__(form, field, name)
+
+ self._conditional_html = {}
+ self.dividers = {}
+
+ def add_conditional_html(self, value, html):
+ if isinstance(self.field.widget, widgets.Select):
+ raise ValueError(
+ "select component does not support conditional fields")
+
+ self._conditional_html[value] = html
+
+ def conditional_html(self, value):
+ return self._conditional_html.get(value)
+
+ def add_divider_after(self, previous, divider):
+ self.dividers[previous] = divider
+
+ def get_divider_after(self, previous):
+ return self.dividers.get(previous)
diff --git a/lung_cancer_screening/nhsuk_forms/choice_field.py b/lung_cancer_screening/nhsuk_forms/choice_field.py
index b306b965..0d9b4713 100644
--- a/lung_cancer_screening/nhsuk_forms/choice_field.py
+++ b/lung_cancer_screening/nhsuk_forms/choice_field.py
@@ -2,7 +2,7 @@
from django.forms import widgets
from django.utils.translation import gettext_lazy as _
-from lung_cancer_screening.core.form_fields import BoundChoiceField
+from .bound_choice_field import BoundChoiceField
class ChoiceField(forms.ChoiceField):
"""
diff --git a/lung_cancer_screening/nhsuk_forms/typed_choice_field.py b/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
index 11907980..69878426 100644
--- a/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
+++ b/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
@@ -3,7 +3,7 @@
from django.forms import widgets
from django.utils.translation import gettext_lazy as _
-from lung_cancer_screening.core.form_fields import BoundChoiceField
+from .bound_choice_field import BoundChoiceField
class TypedChoiceField(forms.TypedChoiceField):
"""
From 28414d63128a2048d0e346f27ab0852370446a5c Mon Sep 17 00:00:00 2001
From: Andy Mitchell <326561+Themitchell@users.noreply.github.com>
Date: Wed, 22 Oct 2025 12:01:56 +0100
Subject: [PATCH 6/9] Fix linting issues after form fields migration
---
lung_cancer_screening/core/form_fields.py | 2 --
lung_cancer_screening/nhsuk_forms/bound_choice_field.py | 4 ++--
lung_cancer_screening/nhsuk_forms/choice_field.py | 1 -
lung_cancer_screening/nhsuk_forms/integer_field.py | 1 -
lung_cancer_screening/nhsuk_forms/typed_choice_field.py | 1 -
5 files changed, 2 insertions(+), 7 deletions(-)
diff --git a/lung_cancer_screening/core/form_fields.py b/lung_cancer_screening/core/form_fields.py
index 14efc55a..5d41ffcb 100644
--- a/lung_cancer_screening/core/form_fields.py
+++ b/lung_cancer_screening/core/form_fields.py
@@ -1,8 +1,6 @@
from django import forms
from django.forms import widgets
-from django.utils.translation import gettext_lazy as _
-from lung_cancer_screening.nhsuk_forms.utils.date_formatting import format_date
from lung_cancer_screening.nhsuk_forms.integer_field import IntegerField
class DecimalField(forms.DecimalField):
diff --git a/lung_cancer_screening/nhsuk_forms/bound_choice_field.py b/lung_cancer_screening/nhsuk_forms/bound_choice_field.py
index 77f0dfb9..c93f93a6 100644
--- a/lung_cancer_screening/nhsuk_forms/bound_choice_field.py
+++ b/lung_cancer_screening/nhsuk_forms/bound_choice_field.py
@@ -1,7 +1,7 @@
from django import forms
from django.forms import widgets
-from django.utils.translation import gettext_lazy as _
+from lung_cancer_screening.nhsuk_forms import choice_field
class BoundChoiceField(forms.BoundField):
"""
@@ -11,7 +11,7 @@ class BoundChoiceField(forms.BoundField):
more details.
"""
- def __init__(self, form: forms.Form, field: "ChoiceField", name: str):
+ def __init__(self, form: forms.Form, field: "choice_field", name: str):
super().__init__(form, field, name)
self._conditional_html = {}
diff --git a/lung_cancer_screening/nhsuk_forms/choice_field.py b/lung_cancer_screening/nhsuk_forms/choice_field.py
index 0d9b4713..e23ee9ef 100644
--- a/lung_cancer_screening/nhsuk_forms/choice_field.py
+++ b/lung_cancer_screening/nhsuk_forms/choice_field.py
@@ -1,6 +1,5 @@
from django import forms
from django.forms import widgets
-from django.utils.translation import gettext_lazy as _
from .bound_choice_field import BoundChoiceField
diff --git a/lung_cancer_screening/nhsuk_forms/integer_field.py b/lung_cancer_screening/nhsuk_forms/integer_field.py
index bed29586..5fc954fd 100644
--- a/lung_cancer_screening/nhsuk_forms/integer_field.py
+++ b/lung_cancer_screening/nhsuk_forms/integer_field.py
@@ -1,5 +1,4 @@
from django import forms
-from django.utils.translation import gettext_lazy as _
class IntegerField(forms.IntegerField):
diff --git a/lung_cancer_screening/nhsuk_forms/typed_choice_field.py b/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
index 69878426..f2bef19e 100644
--- a/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
+++ b/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
@@ -1,7 +1,6 @@
from django import forms
from django.forms import widgets
-from django.utils.translation import gettext_lazy as _
from .bound_choice_field import BoundChoiceField
From 409c417a07c826d5ae9422968844f3ba11078659 Mon Sep 17 00:00:00 2001
From: Andy Mitchell <326561+Themitchell@users.noreply.github.com>
Date: Tue, 28 Oct 2025 11:58:37 +0000
Subject: [PATCH 7/9] Add DecimalField to nhsuk_forms
---
lung_cancer_screening/core/form_fields.py | 27 ------------------
.../nhsuk_forms/decimal_field.py | 28 +++++++++++++++++++
.../questions/forms/metric_height_form.py | 2 +-
.../questions/forms/metric_weight_form.py | 2 +-
4 files changed, 30 insertions(+), 29 deletions(-)
create mode 100644 lung_cancer_screening/nhsuk_forms/decimal_field.py
diff --git a/lung_cancer_screening/core/form_fields.py b/lung_cancer_screening/core/form_fields.py
index 5d41ffcb..cca2ecdf 100644
--- a/lung_cancer_screening/core/form_fields.py
+++ b/lung_cancer_screening/core/form_fields.py
@@ -3,33 +3,6 @@
from lung_cancer_screening.nhsuk_forms.integer_field import IntegerField
-class DecimalField(forms.DecimalField):
- def __init__(
- self,
- *args,
- hint=None,
- label_classes=None,
- classes=None,
- **kwargs,
- ):
- 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 min/max/step attributes.
- attrs.pop("min", None)
- attrs.pop("max", None)
- attrs.pop("step", None)
-
- return attrs
-
class ImperialHeightWidget(widgets.MultiWidget):
"""
diff --git a/lung_cancer_screening/nhsuk_forms/decimal_field.py b/lung_cancer_screening/nhsuk_forms/decimal_field.py
new file mode 100644
index 00000000..f0f41f52
--- /dev/null
+++ b/lung_cancer_screening/nhsuk_forms/decimal_field.py
@@ -0,0 +1,28 @@
+from django import forms
+
+class DecimalField(forms.DecimalField):
+ def __init__(
+ self,
+ *args,
+ hint=None,
+ label_classes=None,
+ classes=None,
+ **kwargs,
+ ):
+ 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 min/max/step attributes.
+ attrs.pop("min", None)
+ attrs.pop("max", None)
+ attrs.pop("step", None)
+
+ return attrs
diff --git a/lung_cancer_screening/questions/forms/metric_height_form.py b/lung_cancer_screening/questions/forms/metric_height_form.py
index 0ebecd9e..73669a8e 100644
--- a/lung_cancer_screening/questions/forms/metric_height_form.py
+++ b/lung_cancer_screening/questions/forms/metric_height_form.py
@@ -1,6 +1,6 @@
from django import forms
-from lung_cancer_screening.core.form_fields import DecimalField
+from ...nhsuk_forms.decimal_field import DecimalField
from ..models.response_set import ResponseSet
class MetricHeightForm(forms.ModelForm):
diff --git a/lung_cancer_screening/questions/forms/metric_weight_form.py b/lung_cancer_screening/questions/forms/metric_weight_form.py
index 4e9c3b14..338a68aa 100644
--- a/lung_cancer_screening/questions/forms/metric_weight_form.py
+++ b/lung_cancer_screening/questions/forms/metric_weight_form.py
@@ -1,6 +1,6 @@
from django import forms
-from lung_cancer_screening.core.form_fields import DecimalField
+from ...nhsuk_forms.decimal_field import DecimalField
from ..models.response_set import ResponseSet
class MetricWeightForm(forms.ModelForm):
From 3f708bd50744d77b57f7c585a0d65818f1e38184 Mon Sep 17 00:00:00 2001
From: Andy Mitchell <326561+Themitchell@users.noreply.github.com>
Date: Tue, 28 Oct 2025 12:02:24 +0000
Subject: [PATCH 8/9] Move Imperial height and weight form fields to
nhsuk_forms
---
.../core/tests/unit/__init__.py | 0
.../nhsuk_forms/imperial_height_form.py | 76 +++++++++++++++++++
.../imperial_weight_form.py} | 73 ------------------
.../questions/forms/imperial_height_form.py | 2 +-
.../questions/forms/imperial_weight_form.py | 2 +-
5 files changed, 78 insertions(+), 75 deletions(-)
delete mode 100644 lung_cancer_screening/core/tests/unit/__init__.py
create mode 100644 lung_cancer_screening/nhsuk_forms/imperial_height_form.py
rename lung_cancer_screening/{core/form_fields.py => nhsuk_forms/imperial_weight_form.py} (56%)
diff --git a/lung_cancer_screening/core/tests/unit/__init__.py b/lung_cancer_screening/core/tests/unit/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/lung_cancer_screening/nhsuk_forms/imperial_height_form.py b/lung_cancer_screening/nhsuk_forms/imperial_height_form.py
new file mode 100644
index 00000000..504c23df
--- /dev/null
+++ b/lung_cancer_screening/nhsuk_forms/imperial_height_form.py
@@ -0,0 +1,76 @@
+from django import forms
+from django.forms import widgets
+
+from lung_cancer_screening.nhsuk_forms.integer_field import IntegerField
+
+
+class ImperialHeightWidget(widgets.MultiWidget):
+ """
+ A widget that splits height into feet and inches inputs.
+ """
+
+ def __init__(self, attrs=None):
+ height_widgets = (
+ widgets.NumberInput(attrs=attrs),
+ widgets.NumberInput(attrs=attrs),
+ )
+ super().__init__(height_widgets, attrs)
+
+ def decompress(self, value):
+ """
+ Convert total inches back to feet and inches for display.
+ """
+ if value:
+ feet = int(value // 12)
+ inches = value % 12
+ return [feet, inches]
+ return [None, None]
+
+ def subwidgets(self, name, value, attrs=None):
+ """
+ Expose data for each subwidget, so that we can render them separately in the template.
+ """
+ context = self.get_context(name, value, attrs)
+ for subwidget in context["widget"]["subwidgets"]:
+ yield subwidget
+
+
+class ImperialHeightField(forms.MultiValueField):
+ """
+ A field that combines feet and inches into a single height value in cm.
+ """
+
+ widget = ImperialHeightWidget
+
+ def __init__(self, *args, **kwargs):
+ error_messages = kwargs.get("error_messages", {})
+
+ feet_kwargs = {
+ "error_messages": {
+ 'invalid': 'Feet must be in whole numbers.',
+ **error_messages,
+ },
+ }
+ inches_kwargs = {
+ "error_messages": {
+ 'invalid': 'Inches must be in whole numbers.',
+ **error_messages,
+ },
+ }
+ fields = (
+ IntegerField(**feet_kwargs),
+ IntegerField(**inches_kwargs),
+ )
+ kwargs["template_name"] = "forms/imperial-height-input.jinja"
+
+ super().__init__(fields, *args, **kwargs)
+
+ def compress(self, data_list):
+ """
+ Convert feet and inches to total inches.
+ """
+ if data_list and all(data_list):
+ feet, inches = data_list
+ total_inches = feet * 12 + inches
+ return int(total_inches)
+ return None
diff --git a/lung_cancer_screening/core/form_fields.py b/lung_cancer_screening/nhsuk_forms/imperial_weight_form.py
similarity index 56%
rename from lung_cancer_screening/core/form_fields.py
rename to lung_cancer_screening/nhsuk_forms/imperial_weight_form.py
index cca2ecdf..bab9355e 100644
--- a/lung_cancer_screening/core/form_fields.py
+++ b/lung_cancer_screening/nhsuk_forms/imperial_weight_form.py
@@ -3,79 +3,6 @@
from lung_cancer_screening.nhsuk_forms.integer_field import IntegerField
-
-class ImperialHeightWidget(widgets.MultiWidget):
- """
- A widget that splits height into feet and inches inputs.
- """
-
- def __init__(self, attrs=None):
- height_widgets = (
- widgets.NumberInput(attrs=attrs),
- widgets.NumberInput(attrs=attrs),
- )
- super().__init__(height_widgets, attrs)
-
- def decompress(self, value):
- """
- Convert total inches back to feet and inches for display.
- """
- if value:
- feet = int(value // 12)
- inches = value % 12
- return [feet, inches]
- return [None, None]
-
- def subwidgets(self, name, value, attrs=None):
- """
- Expose data for each subwidget, so that we can render them separately in the template.
- """
- context = self.get_context(name, value, attrs)
- for subwidget in context["widget"]["subwidgets"]:
- yield subwidget
-
-
-class ImperialHeightField(forms.MultiValueField):
- """
- A field that combines feet and inches into a single height value in cm.
- """
-
- widget = ImperialHeightWidget
-
- def __init__(self, *args, **kwargs):
- error_messages = kwargs.get("error_messages", {})
-
- feet_kwargs = {
- "error_messages": {
- 'invalid': 'Feet must be in whole numbers.',
- **error_messages,
- },
- }
- inches_kwargs = {
- "error_messages": {
- 'invalid': 'Inches must be in whole numbers.',
- **error_messages,
- },
- }
- fields = (
- IntegerField(**feet_kwargs),
- IntegerField(**inches_kwargs),
- )
- kwargs["template_name"] = "forms/imperial-height-input.jinja"
-
- super().__init__(fields, *args, **kwargs)
-
- def compress(self, data_list):
- """
- Convert feet and inches to total inches.
- """
- if data_list and all(data_list):
- feet, inches = data_list
- total_inches = feet * 12 + inches
- return int(total_inches)
- return None
-
-
class ImperialWeightWidget(widgets.MultiWidget):
"""
A widget that splits weight into stone and pounds inputs.
diff --git a/lung_cancer_screening/questions/forms/imperial_height_form.py b/lung_cancer_screening/questions/forms/imperial_height_form.py
index d75cd293..f9ec5794 100644
--- a/lung_cancer_screening/questions/forms/imperial_height_form.py
+++ b/lung_cancer_screening/questions/forms/imperial_height_form.py
@@ -1,6 +1,6 @@
from django import forms
-from lung_cancer_screening.core.form_fields import ImperialHeightField
+from ...nhsuk_forms.imperial_height_form import ImperialHeightField
from ..models.response_set import ResponseSet
class ImperialHeightForm(forms.ModelForm):
diff --git a/lung_cancer_screening/questions/forms/imperial_weight_form.py b/lung_cancer_screening/questions/forms/imperial_weight_form.py
index 69066891..2420c6ce 100644
--- a/lung_cancer_screening/questions/forms/imperial_weight_form.py
+++ b/lung_cancer_screening/questions/forms/imperial_weight_form.py
@@ -1,6 +1,6 @@
from django import forms
-from lung_cancer_screening.core.form_fields import ImperialWeightField
+from ...nhsuk_forms.imperial_weight_form import ImperialWeightField
from ..models.response_set import ResponseSet
class ImperialWeightForm(forms.ModelForm):
From d2c241d144ed27842244ef352fc2545c5664a88d Mon Sep 17 00:00:00 2001
From: Andy Mitchell <326561+Themitchell@users.noreply.github.com>
Date: Tue, 28 Oct 2025 12:05:17 +0000
Subject: [PATCH 9/9] Move form jinja templates to nhsuk_forms
---
lung_cancer_screening/nhsuk_forms/choice_field.py | 2 +-
lung_cancer_screening/nhsuk_forms/decimal_field.py | 2 +-
lung_cancer_screening/nhsuk_forms/imperial_height_form.py | 2 +-
lung_cancer_screening/nhsuk_forms/imperial_weight_form.py | 2 +-
lung_cancer_screening/nhsuk_forms/integer_field.py | 2 +-
.../{core/jinja2/forms => nhsuk_forms/jinja2}/date-input.jinja | 0
.../forms => nhsuk_forms/jinja2}/imperial-height-input.jinja | 0
.../forms => nhsuk_forms/jinja2}/imperial-weight-input.jinja | 0
.../{core/jinja2/forms => nhsuk_forms/jinja2}/input.jinja | 0
.../{core/jinja2/forms => nhsuk_forms/jinja2}/radios.jinja | 0
lung_cancer_screening/nhsuk_forms/split_date_field.py | 2 +-
lung_cancer_screening/nhsuk_forms/typed_choice_field.py | 2 +-
12 files changed, 7 insertions(+), 7 deletions(-)
rename lung_cancer_screening/{core/jinja2/forms => nhsuk_forms/jinja2}/date-input.jinja (100%)
rename lung_cancer_screening/{core/jinja2/forms => nhsuk_forms/jinja2}/imperial-height-input.jinja (100%)
rename lung_cancer_screening/{core/jinja2/forms => nhsuk_forms/jinja2}/imperial-weight-input.jinja (100%)
rename lung_cancer_screening/{core/jinja2/forms => nhsuk_forms/jinja2}/input.jinja (100%)
rename lung_cancer_screening/{core/jinja2/forms => nhsuk_forms/jinja2}/radios.jinja (100%)
diff --git a/lung_cancer_screening/nhsuk_forms/choice_field.py b/lung_cancer_screening/nhsuk_forms/choice_field.py
index e23ee9ef..45db783a 100644
--- a/lung_cancer_screening/nhsuk_forms/choice_field.py
+++ b/lung_cancer_screening/nhsuk_forms/choice_field.py
@@ -32,4 +32,4 @@ def __init__(
@staticmethod
def _template_name(widget):
- return "forms/radios.jinja"
+ return "radios.jinja"
diff --git a/lung_cancer_screening/nhsuk_forms/decimal_field.py b/lung_cancer_screening/nhsuk_forms/decimal_field.py
index f0f41f52..0a7520d3 100644
--- a/lung_cancer_screening/nhsuk_forms/decimal_field.py
+++ b/lung_cancer_screening/nhsuk_forms/decimal_field.py
@@ -9,7 +9,7 @@ def __init__(
classes=None,
**kwargs,
):
- kwargs["template_name"] = "forms/input.jinja"
+ kwargs["template_name"] = "input.jinja"
self.hint = hint
self.classes = classes
diff --git a/lung_cancer_screening/nhsuk_forms/imperial_height_form.py b/lung_cancer_screening/nhsuk_forms/imperial_height_form.py
index 504c23df..655f74bd 100644
--- a/lung_cancer_screening/nhsuk_forms/imperial_height_form.py
+++ b/lung_cancer_screening/nhsuk_forms/imperial_height_form.py
@@ -61,7 +61,7 @@ def __init__(self, *args, **kwargs):
IntegerField(**feet_kwargs),
IntegerField(**inches_kwargs),
)
- kwargs["template_name"] = "forms/imperial-height-input.jinja"
+ kwargs["template_name"] = "imperial-height-input.jinja"
super().__init__(fields, *args, **kwargs)
diff --git a/lung_cancer_screening/nhsuk_forms/imperial_weight_form.py b/lung_cancer_screening/nhsuk_forms/imperial_weight_form.py
index bab9355e..0e323701 100644
--- a/lung_cancer_screening/nhsuk_forms/imperial_weight_form.py
+++ b/lung_cancer_screening/nhsuk_forms/imperial_weight_form.py
@@ -68,7 +68,7 @@ def __init__(self, *args, **kwargs):
IntegerField(**stone_kwargs),
IntegerField(**pounds_kwargs),
)
- kwargs["template_name"] = "forms/imperial-weight-input.jinja"
+ kwargs["template_name"] = "imperial-weight-input.jinja"
super().__init__(fields, *args, **kwargs)
diff --git a/lung_cancer_screening/nhsuk_forms/integer_field.py b/lung_cancer_screening/nhsuk_forms/integer_field.py
index 5fc954fd..8e513dd2 100644
--- a/lung_cancer_screening/nhsuk_forms/integer_field.py
+++ b/lung_cancer_screening/nhsuk_forms/integer_field.py
@@ -10,7 +10,7 @@ def __init__(
classes=None,
**kwargs,
):
- kwargs["template_name"] = "forms/input.jinja"
+ kwargs["template_name"] = "input.jinja"
self.hint = hint
self.classes = classes
diff --git a/lung_cancer_screening/core/jinja2/forms/date-input.jinja b/lung_cancer_screening/nhsuk_forms/jinja2/date-input.jinja
similarity index 100%
rename from lung_cancer_screening/core/jinja2/forms/date-input.jinja
rename to lung_cancer_screening/nhsuk_forms/jinja2/date-input.jinja
diff --git a/lung_cancer_screening/core/jinja2/forms/imperial-height-input.jinja b/lung_cancer_screening/nhsuk_forms/jinja2/imperial-height-input.jinja
similarity index 100%
rename from lung_cancer_screening/core/jinja2/forms/imperial-height-input.jinja
rename to lung_cancer_screening/nhsuk_forms/jinja2/imperial-height-input.jinja
diff --git a/lung_cancer_screening/core/jinja2/forms/imperial-weight-input.jinja b/lung_cancer_screening/nhsuk_forms/jinja2/imperial-weight-input.jinja
similarity index 100%
rename from lung_cancer_screening/core/jinja2/forms/imperial-weight-input.jinja
rename to lung_cancer_screening/nhsuk_forms/jinja2/imperial-weight-input.jinja
diff --git a/lung_cancer_screening/core/jinja2/forms/input.jinja b/lung_cancer_screening/nhsuk_forms/jinja2/input.jinja
similarity index 100%
rename from lung_cancer_screening/core/jinja2/forms/input.jinja
rename to lung_cancer_screening/nhsuk_forms/jinja2/input.jinja
diff --git a/lung_cancer_screening/core/jinja2/forms/radios.jinja b/lung_cancer_screening/nhsuk_forms/jinja2/radios.jinja
similarity index 100%
rename from lung_cancer_screening/core/jinja2/forms/radios.jinja
rename to lung_cancer_screening/nhsuk_forms/jinja2/radios.jinja
diff --git a/lung_cancer_screening/nhsuk_forms/split_date_field.py b/lung_cancer_screening/nhsuk_forms/split_date_field.py
index 17653587..59569757 100644
--- a/lung_cancer_screening/nhsuk_forms/split_date_field.py
+++ b/lung_cancer_screening/nhsuk_forms/split_date_field.py
@@ -110,7 +110,7 @@ def __init__(self, *args, **kwargs):
IntegerField(**year_kwargs),
]
- kwargs["template_name"] = "forms/date-input.jinja"
+ kwargs["template_name"] = "date-input.jinja"
super().__init__(self.fields, *args, **kwargs)
diff --git a/lung_cancer_screening/nhsuk_forms/typed_choice_field.py b/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
index f2bef19e..0a35aae2 100644
--- a/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
+++ b/lung_cancer_screening/nhsuk_forms/typed_choice_field.py
@@ -33,4 +33,4 @@ def __init__(
@staticmethod
def _template_name(widget):
- return "forms/radios.jinja"
+ return "radios.jinja"