diff --git a/django/forms/fields.py b/django/forms/fields.py index 1a58a60743..1be74e799a 100644 --- a/django/forms/fields.py +++ b/django/forms/fields.py @@ -18,7 +18,6 @@ from urllib.parse import urlsplit, urlunsplit from django.conf import settings from django.core import validators from django.core.exceptions import ValidationError -from django.forms.boundfield import BoundField from django.forms.utils import from_current_timezone, to_current_timezone from django.forms.widgets import ( FILE_INPUT_CONTRADICTION, @@ -95,6 +94,7 @@ class Field: "required": _("This field is required."), } empty_values = list(validators.EMPTY_VALUES) + bound_field_class = None def __init__( self, @@ -111,6 +111,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 +136,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 +254,8 @@ class Field: Return a BoundField instance that will be used when accessing the form field in a template. """ - return BoundField(form, self, field_name) + BoundFieldClass = self.bound_field_class or form.bound_field_class + return BoundFieldClass(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..965f550364 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 self.renderer.bound_field_class + ) + 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..b6512824d3 100644 --- a/django/forms/renderers.py +++ b/django/forms/renderers.py @@ -21,6 +21,12 @@ class BaseRenderer: formset_template_name = "django/forms/formsets/div.html" field_template_name = "django/forms/field.html" + @property + def bound_field_class(self): + from django.forms.boundfield import BoundField + + return BoundField + 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 494f3f244e..ba807a5b3b 100644 --- a/docs/ref/forms/api.txt +++ b/docs/ref/forms/api.txt @@ -934,6 +934,30 @@ When set to ``True`` (the default), required form fields will have the ``use_required_attribute=False`` to avoid incorrect browser validation when adding and deleting forms from a formset. +.. attribute:: Form.bound_field_class + +.. versionadded:: 5.2 + +Specify a :class:`~django.forms.BoundField` class to be used on the form. For +example:: + + from django import forms + + + class CustomBoundField(forms.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 MyForm(forms.Form): + bound_field_class = CustomBoundField + +This takes precedence over :attr:`.BaseRenderer.bound_field_class`. + +See :ref:`custom-boundfield` for examples of overriding a ``BoundField``. + Configuring the rendering of a form's widgets --------------------------------------------- @@ -1125,6 +1149,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 use a different ``BoundField`` class + per-form or per-field. + + 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: @@ -1464,26 +1494,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`. +There are multiple ways to use a custom :class:`.BoundField`: -A custom form field can override ``get_bound_field()``: +* A project can define a project-wide :class:`.BoundField` class using + :attr:`.BaseRenderer.bound_field_class` and :setting:`FORM_RENDERER`. -.. method:: Field.get_bound_field(form, field_name) +* A form can override :attr:`.Form.bound_field_class` or set the constructor + ``bound_field_class`` argument in the constructor. - 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`. +* A custom form field can override :attr:`.Field.bound_field_class` or set the + ``bound_field_class`` argument in the constructor. -If you have a ``GPSCoordinatesField``, for example, and want to be able to +* Lastly, a custom form field can override :meth:`.Field.get_bound_field()` + though any of the previous options is 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:: + class GPSCoordinatesBoundField(BoundField): @property def country(self): @@ -1499,12 +1537,36 @@ 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) + + +A project can define a project-wise ``BoundField`` class using +:attr:`.BaseRenderer.bound_field_class` and a custom :setting:`FORM_RENDERER`. + .. _binding-uploaded-files: Binding uploaded files to a form diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index 2b4b344844..eb235e8c7e 100644 --- a/docs/ref/forms/fields.txt +++ b/docs/ref/forms/fields.txt @@ -397,6 +397,15 @@ 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`` +--------------------- + +.. versionadded:: 5.2 + +.. attribute:: Field.bound_field_class + +Allows a per-field override of :attr:`.Form.bound_field_class`. + Checking if the field data has changed ====================================== @@ -1626,4 +1635,15 @@ 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 return value will be used when accessing the field in a template. Most + likely it will be an instance of a subclass of + :class:`.BoundField`. + +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..05ccb2eb28 100644 --- a/docs/ref/forms/renderers.txt +++ b/docs/ref/forms/renderers.txt @@ -65,6 +65,17 @@ 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:`~django.forms.BoundField` to use when rendering + forms. If not overridden by :attr:`.Form.bound_field_class` or + :attr:`.Field.bound_field_class`, this is the ``BoundField`` class that + gets used during rendering. + + Defaults to :class:`django.forms.BoundField`. + .. 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 aaf47ff8e8..9acc7c444d 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -50,6 +50,38 @@ be a ``CompositePrimaryKey``:: See :doc:`/topics/composite-primary-key` for more details. +Simplified usage of :class:`~django.forms.BoundField` +----------------------------------------------------- + +Prior to 5.2, it was necessary to override :meth:`.Field.get_bound_field()` to +use custom :class:`~django.forms.BoundField`. Django now supports specifying a +:class:`~django.forms.BoundField` class to use at a project, form and +field level:: + + from django.forms import Form + from django.forms.boundfield import BoundField + from django.forms.renderers import DjangoTemplates + + + class BoundFieldWithCssClass(BoundField): + def css_classes(self, extra_classes=None): + return super().css_classes("user-form") + + + class CustomForm(Form): + # Override per-form. + bound_field_class = BoundFieldWithCssClass + + # Or per-field. + name = CharField(bound_field_class=BoundFieldWithCssClass) + + + # Alternatively, set bound_field_class on your custom FORM_RENDERER. + class CustomRenderer(DjangoTemplates): + bound_field_class = BoundFieldWithCssClass + +See :ref:`custom-boundfield` for more details. + Minor features -------------- diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py index 31ab0a15aa..09d8e71f96 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, @@ -5385,3 +5386,117 @@ class OverrideTests(SimpleTestCase): '' '', ) + + +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 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 = """ +