0
0
mirror of https://github.com/django/django.git synced 2024-11-21 19:09:18 +01:00

Fixed #35192 -- Added possibility to override BoundField at Form level or Field level.

This commit is contained in:
Christophe Henry 2024-06-11 12:25:24 +02:00 committed by Sarah Boyce
parent 17e544ece7
commit 2387592e92
9 changed files with 278 additions and 21 deletions

View File

@ -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)

View File

@ -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.

View File

@ -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()")

View File

@ -934,6 +934,23 @@ 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 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
---------------------------------------------
@ -1106,10 +1123,16 @@ they're not the only way a form object can be displayed.
.. class:: BoundField
Used to display HTML or access attributes for a single field of a
:class:`Form` instance.
Used to display HTML or access attributes for a single field of a
:class:`Form` instance.
The ``__str__()`` method of this object displays the HTML for this field.
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:
@ -1416,7 +1439,7 @@ Methods of ``BoundField``
.. method:: BoundField.render(template_name=None, context=None, renderer=None)
The render method is called by ``as_field_group``. All arguments are
The render method is called by ``as_field_group``. All arguments are
optional and default to:
* ``template_name``: :attr:`.BoundField.template_name`
@ -1441,25 +1464,38 @@ 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-wise :class:`.BoundField` class using
:attr:`.BaseRenderer.bound_field_class` and :setting:`FORM_RENDERER`.
* A form can override :attr:`.Form.bound_field_class` or use the constructor
argument.
* A custom form field can override :attr:`.Field.bound_field_class` or use the
constructor argument.
* A custom form field can override :meth:`.Field.get_bound_field()`:
.. 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:`~django.forms.BoundField`.
likely it will be an instance of a subclass of :class:`.BoundField`.
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``, for example, and want to be
able to access additional information about the coordinates in a template, this
could be implemented as follows::
If you have a ``GPSCoordinatesField``, for example, 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
@ -1476,12 +1512,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)
<label for="id_name" class="wide">Name:</label>
A project can define a project-wise ``BoundField`` class using
:attr:`.BaseRenderer.bound_field_class` and :setting:`FORM_RENDERER`.
.. _binding-uploaded-files:
Binding uploaded files to a form

View File

@ -392,11 +392,20 @@ be ignored in favor of the value from the form's initial data.
.. attribute:: Field.template_name
The ``template_name`` argument allows a custom template to be used when the
field is rendered with :meth:`~django.forms.BoundField.as_field_group`. By
field is rendered with :meth:`~django.forms.BoundField.as_field_group`. By
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,5 @@ 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`. See :ref:`custom-boundfield`
for examples of overriding a ``BoundField``.

View File

@ -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

View File

@ -31,6 +31,38 @@ and only officially support the latest release of each series.
What's new in Django 5.2
========================
Simplified usage of :class:`~django.forms.BoundField`
-----------------------------------------------------
Django now supports specifying a custom :class:`~django.forms.BoundField` class
to use at a project, form and field level. This allows fine-grained usage of
the :ref:`ref-forms-api-bound-unbound`::
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 :attr:`.BaseRenderer.bound_field_class`, :attr:`.Form.bound_field_class`
and :attr:`.Field.bound_field_class` for more details.
Minor features
--------------

View File

@ -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,
@ -5329,3 +5330,120 @@ class OverrideTests(SimpleTestCase):
'<label for="id_name" class="required">Name:</label>'
'<legend class="required">Language:</legend>',
)
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 setUp(self):
self.maxDiff = None
def test_renderer_custom_bound_field(self):
t = Template("{{ form }}")
html = t.render(Context({"form": Person()}))
expected = """
<div><label for="id_first_name">First name</label>
<input type="text" name="first_name" required
id="id_first_name"></div>
<div><label for="id_last_name">Last name</label>
<input type="text" name="last_name" required
id="id_last_name"></div><div>
<label for="id_birthday">Birthday</label>
<input type="text" name="birthday" required
id="id_birthday"></div>"""
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 = """
<div><label for="id_first_name">First name::</label>
<input type="text" name="first_name" required
id="id_first_name"></div>
<div><label for="id_last_name">Last name::</label>
<input type="text" name="last_name" required
id="id_last_name"></div><div>
<label for="id_birthday">Birthday::</label>
<input type="text" name="birthday" required
id="id_birthday"></div>"""
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 = """
<div><label class="custom-class" for="id_first_name">First name:</label>
<input type="text" name="first_name" required
id="id_first_name"></div>
<div><label class="custom-class" for="id_last_name">Last name:</label>
<input type="text" name="last_name" required
id="id_last_name"></div><div>
<label class="custom-class" for="id_birthday">Birthday:</label>
<input type="text" name="birthday" required
id="id_birthday"></div>"""
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 = """
<div><label for="id_first_name">First name::</label>
<input type="text" name="first_name" required
id="id_first_name"></div>
<div><label class="custom-class" for="id_last_name">Last name:</label>
<input type="text" name="last_name" required
id="id_last_name"></div><div>
<label for="id_birthday">Birthday:</label>
<input type="text" name="birthday" required
id="id_birthday"></div>"""
self.assertHTMLEqual(html, expected)

View File

@ -847,8 +847,15 @@ class ModelFormBaseTest(TestCase):
self.assertEqual(m1.mode, mode)
def test_renderer_kwarg(self):
custom = object()
self.assertIs(ProductForm(renderer=custom).renderer, custom)
from django.forms.renderers import BaseRenderer
class CustomRenderer(BaseRenderer):
custom_attribute = "test"
custom = CustomRenderer()
form = ProductForm(renderer=custom)
self.assertIs(form.renderer, custom)
self.assertEqual(form.renderer.custom_attribute, "test")
def test_default_splitdatetime_field(self):
class PubForm(forms.ModelForm):