diff --git a/django/forms/fields.py b/django/forms/fields.py
index 202a6d72c8..4bd9c352f2 100644
--- a/django/forms/fields.py
+++ b/django/forms/fields.py
@@ -95,6 +95,7 @@ class Field:
"required": _("This field is required."),
}
empty_values = list(validators.EMPTY_VALUES)
+ bound_field_class = None
def __init__(
self,
@@ -111,6 +112,7 @@ class Field:
disabled=False,
label_suffix=None,
template_name=None,
+ bound_field_class=None,
):
# required -- Boolean that specifies whether the field is required.
# True by default.
@@ -135,11 +137,13 @@ class Field:
# is its widget is shown in the form but not editable.
# label_suffix -- Suffix to be added to the label. Overrides
# form's label_suffix.
+ # bound_field_class -- BoundField class to use in Field.get_bound_field.
self.required, self.label, self.initial = required, label, initial
self.show_hidden_initial = show_hidden_initial
self.help_text = help_text
self.disabled = disabled
self.label_suffix = label_suffix
+ self.bound_field_class = bound_field_class or self.bound_field_class
widget = widget or self.widget
if isinstance(widget, type):
widget = widget()
@@ -251,7 +255,10 @@ class Field:
Return a BoundField instance that will be used when accessing the form
field in a template.
"""
- return BoundField(form, self, field_name)
+ bound_field_class = (
+ self.bound_field_class or form.bound_field_class or BoundField
+ )
+ return bound_field_class(form, self, field_name)
def __deepcopy__(self, memo):
result = copy.copy(self)
diff --git a/django/forms/forms.py b/django/forms/forms.py
index 614f990395..844f15f9f2 100644
--- a/django/forms/forms.py
+++ b/django/forms/forms.py
@@ -68,6 +68,8 @@ class BaseForm(RenderableFormMixin):
template_name_ul = "django/forms/ul.html"
template_name_label = "django/forms/label.html"
+ bound_field_class = None
+
def __init__(
self,
data=None,
@@ -81,6 +83,7 @@ class BaseForm(RenderableFormMixin):
field_order=None,
use_required_attribute=None,
renderer=None,
+ bound_field_class=None,
):
self.is_bound = data is not None or files is not None
self.data = MultiValueDict() if data is None else data
@@ -124,6 +127,12 @@ class BaseForm(RenderableFormMixin):
renderer = renderer()
self.renderer = renderer
+ self.bound_field_class = (
+ bound_field_class
+ or self.bound_field_class
+ or getattr(self.renderer, "bound_field_class", None)
+ )
+
def order_fields(self, field_order):
"""
Rearrange the fields according to field_order.
diff --git a/django/forms/renderers.py b/django/forms/renderers.py
index baf8f74507..20eaf265df 100644
--- a/django/forms/renderers.py
+++ b/django/forms/renderers.py
@@ -21,6 +21,8 @@ class BaseRenderer:
formset_template_name = "django/forms/formsets/div.html"
field_template_name = "django/forms/field.html"
+ bound_field_class = None
+
def get_template(self, template_name):
raise NotImplementedError("subclasses must implement get_template()")
diff --git a/docs/ref/forms/api.txt b/docs/ref/forms/api.txt
index 1b1ecbec6c..9b827ca69a 100644
--- a/docs/ref/forms/api.txt
+++ b/docs/ref/forms/api.txt
@@ -822,6 +822,9 @@ classes, as needed. The HTML will look something like:
>>> f["subject"].legend_tag(attrs={"class": "foo"})
Subject:
+You may further modify the rendering of form rows by using a
+:ref:`custom BoundField `.
+
.. _ref-forms-api-configuring-label:
Configuring form elements' HTML ``id`` attributes and ```` tags
@@ -1149,6 +1152,12 @@ they're not the only way a form object can be displayed.
The ``__str__()`` method of this object displays the HTML for this field.
+ You can use :attr:`.Form.bound_field_class` and
+ :attr:`.Field.bound_field_class` to specify a different ``BoundField`` class
+ per form or per field, respectively.
+
+ See :ref:`custom-boundfield` for examples of overriding a ``BoundField``.
+
To retrieve a single ``BoundField``, use dictionary lookup syntax on your form
using the field's name as the key:
@@ -1488,23 +1497,34 @@ Methods of ``BoundField``
>>> print(bound_form["subject"].value())
hi
+.. _custom-boundfield:
+
Customizing ``BoundField``
==========================
-If you need to access some additional information about a form field in a
-template and using a subclass of :class:`~django.forms.Field` isn't
-sufficient, consider also customizing :class:`~django.forms.BoundField`.
+.. attribute:: Form.bound_field_class
-A custom form field can override ``get_bound_field()``:
+.. versionadded:: 5.2
-.. method:: Field.get_bound_field(form, field_name)
+Define a custom :class:`~django.forms.BoundField` class to use when rendering
+the form. This takes precedence over the project-level
+:attr:`.BaseRenderer.bound_field_class` (along with a custom
+:setting:`FORM_RENDERER`), but can be overridden by the field-level
+:attr:`.Field.bound_field_class`.
- Takes an instance of :class:`~django.forms.Form` and the name of the field.
- The return value will be used when accessing the field in a template. Most
- likely it will be an instance of a subclass of
- :class:`~django.forms.BoundField`.
+If not defined as a class variable, ``bound_field_class`` can be set via the
+``bound_field_class`` argument in the :class:`Form` or :class:`Field`
+constructor.
-If you have a ``GPSCoordinatesField``, for example, and want to be able to
+For compatibility reasons, a custom form field can still override
+:meth:`.Field.get_bound_field()` to use a custom class, though any of the
+previous options are preferred.
+
+You may want to use a custom :class:`.BoundField` if you need to access some
+additional information about a form field in a template and using a subclass of
+:class:`~django.forms.Field` isn't sufficient.
+
+For example, if you have a ``GPSCoordinatesField``, and want to be able to
access additional information about the coordinates in a template, this could
be implemented as follows::
@@ -1523,12 +1543,74 @@ be implemented as follows::
class GPSCoordinatesField(Field):
- def get_bound_field(self, form, field_name):
- return GPSCoordinatesBoundField(form, self, field_name)
+ bound_field_class = GPSCoordinatesBoundField
Now you can access the country in a template with
``{{ form.coordinates.country }}``.
+You may also want to customize the default form field template rendering. For
+example, you can override :meth:`.BoundField.label_tag` to add a custom class::
+
+ class StyledLabelBoundField(BoundField):
+ def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+ attrs = attrs or {}
+ attrs["class"] = "wide"
+ return super().label_tag(contents, attrs, label_suffix, tag)
+
+
+ class UserForm(forms.Form):
+ bound_field_class = StyledLabelBoundField
+ name = CharField()
+
+This would update the default form rendering:
+
+.. code-block:: pycon
+
+ >>> f = UserForm()
+ >>> print(f["name"].label_tag)
+ Name:
+
+To add a CSS class to the wrapping HTML element of all fields, a ``BoundField``
+can be overridden to return a different collection of CSS classes::
+
+ class WrappedBoundField(BoundField):
+ def css_classes(self, extra_classes=None):
+ parent_css_classes = super().css_classes(extra_classes)
+ return f"field-class {parent_css_classes}".strip()
+
+
+ class UserForm(forms.Form):
+ bound_field_class = WrappedBoundField
+ name = CharField()
+
+This would update the form rendering as follows:
+
+.. code-block:: pycon
+
+ >>> f = UserForm()
+ >>> print(f)
+ Name:
+
+Alternatively, to override the ``BoundField`` class at the project level,
+:attr:`.BaseRenderer.bound_field_class` can be defined on a custom
+:setting:`FORM_RENDERER`:
+
+.. code-block:: python
+ :caption: ``mysite/renderers.py``
+
+ from django.forms.renderers import DjangoTemplates
+
+ from .forms import CustomBoundField
+
+
+ class CustomRenderer(DjangoTemplates):
+ bound_field_class = CustomBoundField
+
+.. code-block:: python
+ :caption: ``settings.py``
+
+ FORM_RENDERER = "mysite.renderers.CustomRenderer"
+
.. _binding-uploaded-files:
Binding uploaded files to a form
diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt
index c0ab4f2434..7ae5c121eb 100644
--- a/docs/ref/forms/fields.txt
+++ b/docs/ref/forms/fields.txt
@@ -397,6 +397,16 @@ default this value is set to ``"django/forms/field.html"``. Can be changed per
field by overriding this attribute or more generally by overriding the default
template, see also :ref:`overriding-built-in-field-templates`.
+``bound_field_class``
+---------------------
+
+.. attribute:: Field.bound_field_class
+
+.. versionadded:: 5.2
+
+The ``bound_field_class`` attribute allows a per-field override of
+:attr:`.Form.bound_field_class`.
+
Checking if the field data has changed
======================================
@@ -1635,4 +1645,14 @@ only requirements are that it implement a ``clean()`` method and that its
``label``, ``initial``, ``widget``, ``help_text``).
You can also customize how a field will be accessed by overriding
-:meth:`~django.forms.Field.get_bound_field()`.
+:attr:`~django.forms.Field.bound_field_class` or override
+:meth:`.Field.get_bound_field()` if you need more flexibility when creating
+the ``BoundField``:
+
+.. method:: Field.get_bound_field(form, field_name)
+
+ Takes an instance of :class:`~django.forms.Form` and the name of the field.
+ The returned :class:`.BoundField` instance will be used when accessing the
+ field in a template.
+
+See :ref:`custom-boundfield` for examples of overriding a ``BoundField``.
diff --git a/docs/ref/forms/renderers.txt b/docs/ref/forms/renderers.txt
index e527a70c57..f52534651a 100644
--- a/docs/ref/forms/renderers.txt
+++ b/docs/ref/forms/renderers.txt
@@ -65,6 +65,18 @@ should return a rendered templates (as a string) or raise
Defaults to ``"django/forms/field.html"``
+ .. attribute:: bound_field_class
+
+ .. versionadded:: 5.2
+
+ The default class used to represent form fields across the project.
+
+ Defaults to :class:`.BoundField` class.
+
+ This can be customized further using :attr:`.Form.bound_field_class`
+ for per-form overrides, or :attr:`.Field.bound_field_class` for
+ per-field overrides.
+
.. method:: get_template(template_name)
Subclasses must implement this method with the appropriate template
diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt
index a65306d0a1..5b04608cad 100644
--- a/docs/releases/5.2.txt
+++ b/docs/releases/5.2.txt
@@ -70,6 +70,62 @@ to be a ``CompositePrimaryKey``::
See :doc:`/topics/composite-primary-key` for more details.
+Simplified override of :class:`~django.forms.BoundField`
+--------------------------------------------------------
+
+Prior to version 5.2, overriding :meth:`.Field.get_bound_field()` was the only
+option to use a custom :class:`~django.forms.BoundField`. Django now supports
+specifying the following attributes to customize form rendering:
+
+* :attr:`.BaseRenderer.bound_field_class` at the project level,
+* :attr:`.Form.bound_field_class` at the form level, and
+* :attr:`.Field.bound_field_class` at the field level.
+
+For example, to customize the ``BoundField`` of a ``Form`` class::
+
+ from django.forms import Form
+
+
+ class CustomBoundField(forms.BoundField):
+
+ custom_class = "custom"
+
+ def css_classes(self, extra_classes=None):
+ result = super().css_classes(extra_classes)
+ if self.custom_class not in result:
+ result += f" {self.custom_class}"
+ return result.strip()
+
+
+ class CustomForm(forms.Form):
+ bound_field_class = CustomBoundField
+
+ name = forms.CharField(
+ label="Your Name",
+ max_length=100,
+ required=False,
+ widget=forms.TextInput(attrs={"class": "name-input-class"}),
+ )
+ email = forms.EmailField(label="Your Email")
+
+
+When rendering a ``CustomForm`` instance, the following HTML is included:
+
+.. code:: html
+
+
+ Your Name:
+
+
+
+
+ Your Email:
+
+
+
+
+See :ref:`custom-boundfield` for more details about this feature.
+
Minor features
--------------
diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py
index b41424d43d..20c86754c6 100644
--- a/tests/forms_tests/tests/test_forms.py
+++ b/tests/forms_tests/tests/test_forms.py
@@ -8,6 +8,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import MaxValueValidator, RegexValidator
from django.forms import (
BooleanField,
+ BoundField,
CharField,
CheckboxSelectMultiple,
ChoiceField,
@@ -4971,6 +4972,22 @@ class RendererTests(SimpleTestCase):
context = form.get_context()
self.assertEqual(context["errors"].renderer, custom)
+ def test_boundfield_fallback(self):
+ class RendererWithoutBoundFieldClassAttribute:
+ form_template_name = "django/forms/div.html"
+ formset_template_name = "django/forms/formsets/div.html"
+ field_template_name = "django/forms/field.html"
+
+ def render(self, template_name, context, request=None):
+ return "Nice"
+
+ class UserForm(Form):
+ name = CharField()
+
+ form = UserForm(renderer=RendererWithoutBoundFieldClassAttribute())
+ self.assertIsInstance(form["name"], BoundField)
+ self.assertEqual(form["name"].as_field_group(), "Nice")
+
class TemplateTests(SimpleTestCase):
def test_iterate_radios(self):
@@ -5473,3 +5490,146 @@ class OverrideTests(SimpleTestCase):
'Name: '
'Language: ',
)
+
+
+class BoundFieldWithoutColon(BoundField):
+ def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+ return super().label_tag(
+ contents=contents, attrs=attrs, label_suffix="", tag=None
+ )
+
+
+class BoundFieldWithTwoColons(BoundField):
+ def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+ return super().label_tag(
+ contents=contents, attrs=attrs, label_suffix="::", tag=None
+ )
+
+
+class BoundFieldWithCustomClass(BoundField):
+ def label_tag(self, contents=None, attrs=None, label_suffix=None, tag=None):
+ attrs = attrs or {}
+ attrs["class"] = "custom-class"
+ return super().label_tag(contents, attrs, label_suffix, tag)
+
+
+class BoundFieldWithWrappingClass(BoundField):
+ def css_classes(self, extra_classes=None):
+ parent_classes = super().css_classes(extra_classes)
+ return f"field-class {parent_classes}"
+
+
+class BoundFieldOverrideRenderer(DjangoTemplates):
+ bound_field_class = BoundFieldWithoutColon
+
+
+@override_settings(
+ FORM_RENDERER="forms_tests.tests.test_forms.BoundFieldOverrideRenderer"
+)
+class CustomBoundFieldTest(SimpleTestCase):
+ def test_renderer_custom_bound_field(self):
+ t = Template("{{ form }}")
+ html = t.render(Context({"form": Person()}))
+ expected = """
+ First name
+
+ Last name
+
+ Birthday
+
"""
+ self.assertHTMLEqual(html, expected)
+
+ def test_form_custom_boundfield(self):
+ class CustomBoundFieldPerson(Person):
+ bound_field_class = BoundFieldWithTwoColons
+
+ with self.subTest("form's BoundField takes over renderer's BoundField"):
+ t = Template("{{ form }}")
+ html = t.render(Context({"form": CustomBoundFieldPerson()}))
+ expected = """
+ First name::
+
+ Last name::
+
+ Birthday::
+
"""
+ self.assertHTMLEqual(html, expected)
+
+ with self.subTest("Constructor argument takes over class property"):
+ t = Template("{{ form }}")
+ html = t.render(
+ Context(
+ {
+ "form": CustomBoundFieldPerson(
+ bound_field_class=BoundFieldWithCustomClass
+ )
+ }
+ )
+ )
+ expected = """
+ First name:
+
+ Last name:
+
+ Birthday:
+
"""
+ self.assertHTMLEqual(html, expected)
+
+ with self.subTest("Overriding css_classes works as expected"):
+ t = Template("{{ form }}")
+ html = t.render(
+ Context(
+ {
+ "form": CustomBoundFieldPerson(
+ bound_field_class=BoundFieldWithWrappingClass
+ )
+ }
+ )
+ )
+ expected = """
+ First name:
+
+ Last name:
+
+ Birthday:
+
"""
+ self.assertHTMLEqual(html, expected)
+
+ def test_field_custom_bound_field(self):
+ class BoundFieldWithTwoColonsCharField(CharField):
+ bound_field_class = BoundFieldWithTwoColons
+
+ class CustomFieldBoundFieldPerson(Person):
+ bound_field_class = BoundField
+
+ first_name = BoundFieldWithTwoColonsCharField()
+ last_name = BoundFieldWithTwoColonsCharField(
+ bound_field_class=BoundFieldWithCustomClass
+ )
+
+ html = Template("{{ form }}").render(
+ Context({"form": CustomFieldBoundFieldPerson()})
+ )
+ expected = """
+ First name::
+
+ Last name:
+
+ Birthday:
+
"""
+ self.assertHTMLEqual(html, expected)