summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDavid Smith <smithdc@gmail.com>2022-11-02 20:13:16 +0000
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2023-03-24 10:16:30 +0100
commitcad376f844c7bdeeee7607a7c0ea8ae52061309b (patch)
tree674bcfb8a4b99c752bff23c029494987ed62f5ef
parentd33368b4ab6ae0c01e83d525f7e1655156a640d1 (diff)
Fixed #34077 -- Added form field rendering.
-rw-r--r--django/forms/boundfield.py19
-rw-r--r--django/forms/fields.py2
-rw-r--r--django/forms/jinja2/django/forms/div.html11
-rw-r--r--django/forms/jinja2/django/forms/field.html10
-rw-r--r--django/forms/renderers.py1
-rw-r--r--django/forms/templates/django/forms/div.html11
-rw-r--r--django/forms/templates/django/forms/field.html10
-rw-r--r--django/forms/utils.py23
-rw-r--r--docs/ref/forms/api.txt40
-rw-r--r--docs/ref/forms/fields.txt13
-rw-r--r--docs/ref/forms/renderers.txt31
-rw-r--r--docs/releases/5.0.txt63
-rw-r--r--docs/topics/forms/index.txt68
-rw-r--r--tests/forms_tests/templates/forms_tests/custom_field.html3
-rw-r--r--tests/forms_tests/tests/test_forms.py39
-rw-r--r--tests/forms_tests/tests/test_utils.py13
16 files changed, 324 insertions, 33 deletions
diff --git a/django/forms/boundfield.py b/django/forms/boundfield.py
index 6764276148..39b0aaf97b 100644
--- a/django/forms/boundfield.py
+++ b/django/forms/boundfield.py
@@ -1,7 +1,7 @@
import re
from django.core.exceptions import ValidationError
-from django.forms.utils import pretty_name
+from django.forms.utils import RenderableFieldMixin, pretty_name
from django.forms.widgets import MultiWidget, Textarea, TextInput
from django.utils.functional import cached_property
from django.utils.html import format_html, html_safe
@@ -10,8 +10,7 @@ from django.utils.translation import gettext_lazy as _
__all__ = ("BoundField",)
-@html_safe
-class BoundField:
+class BoundField(RenderableFieldMixin):
"A Field plus data"
def __init__(self, form, field, name):
@@ -26,12 +25,7 @@ class BoundField:
else:
self.label = self.field.label
self.help_text = field.help_text or ""
-
- def __str__(self):
- """Render this field as an HTML widget."""
- if self.field.show_hidden_initial:
- return self.as_widget() + self.as_hidden(only_initial=True)
- return self.as_widget()
+ self.renderer = form.renderer
@cached_property
def subwidgets(self):
@@ -81,6 +75,13 @@ class BoundField:
self.name, self.form.error_class(renderer=self.form.renderer)
)
+ @property
+ def template_name(self):
+ return self.field.template_name or self.form.renderer.field_template_name
+
+ def get_context(self):
+ return {"field": self}
+
def as_widget(self, widget=None, attrs=None, only_initial=False):
"""
Render the field by rendering the passed widget, adding any HTML
diff --git a/django/forms/fields.py b/django/forms/fields.py
index 003fb5ca6b..0143296533 100644
--- a/django/forms/fields.py
+++ b/django/forms/fields.py
@@ -107,6 +107,7 @@ class Field:
localize=False,
disabled=False,
label_suffix=None,
+ template_name=None,
):
# required -- Boolean that specifies whether the field is required.
# True by default.
@@ -164,6 +165,7 @@ class Field:
self.error_messages = messages
self.validators = [*self.default_validators, *validators]
+ self.template_name = template_name
super().__init__()
diff --git a/django/forms/jinja2/django/forms/div.html b/django/forms/jinja2/django/forms/div.html
index 6de0bb038e..f297874e4a 100644
--- a/django/forms/jinja2/django/forms/div.html
+++ b/django/forms/jinja2/django/forms/div.html
@@ -4,16 +4,7 @@
{% endif %}
{% for field, errors in fields %}
<div{% set classes = field.css_classes() %}{% if classes %} class="{{ classes }}"{% endif %}>
- {% if field.use_fieldset %}
- <fieldset>
- {% if field.label %}{{ field.legend_tag() }}{% endif %}
- {% else %}
- {% if field.label %}{{ field.label_tag() }}{% endif %}
- {% endif %}
- {% if field.help_text %}<div class="helptext">{{ field.help_text|safe }}</div>{% endif %}
- {{ errors }}
- {{ field }}
- {% if field.use_fieldset %}</fieldset>{% endif %}
+ {{ field.as_field_group() }}
{% if loop.last %}
{% for field in hidden_fields %}{{ field }}{% endfor %}
{% endif %}
diff --git a/django/forms/jinja2/django/forms/field.html b/django/forms/jinja2/django/forms/field.html
new file mode 100644
index 0000000000..56ffa1ad83
--- /dev/null
+++ b/django/forms/jinja2/django/forms/field.html
@@ -0,0 +1,10 @@
+{% if field.use_fieldset %}
+ <fieldset>
+ {% if field.label %}{{ field.legend_tag() }}{% endif %}
+{% else %}
+ {% if field.label %}{{ field.label_tag() }}{% endif %}
+{% endif %}
+{% if field.help_text %}<div class="helptext">{{ field.help_text|safe }}</div>{% endif %}
+{{ field.errors }}
+{{ field }}
+{% if field.use_fieldset %}</fieldset>{% endif %}
diff --git a/django/forms/renderers.py b/django/forms/renderers.py
index 30f8141dee..58abe9ed02 100644
--- a/django/forms/renderers.py
+++ b/django/forms/renderers.py
@@ -19,6 +19,7 @@ def get_default_renderer():
class BaseRenderer:
form_template_name = "django/forms/div.html"
formset_template_name = "django/forms/formsets/div.html"
+ field_template_name = "django/forms/field.html"
def get_template(self, template_name):
raise NotImplementedError("subclasses must implement get_template()")
diff --git a/django/forms/templates/django/forms/div.html b/django/forms/templates/django/forms/div.html
index 0328fdf8d3..c20eead4aa 100644
--- a/django/forms/templates/django/forms/div.html
+++ b/django/forms/templates/django/forms/div.html
@@ -4,16 +4,7 @@
{% endif %}
{% for field, errors in fields %}
<div{% with classes=field.css_classes %}{% if classes %} class="{{ classes }}"{% endif %}{% endwith %}>
- {% if field.use_fieldset %}
- <fieldset>
- {% if field.label %}{{ field.legend_tag }}{% endif %}
- {% else %}
- {% if field.label %}{{ field.label_tag }}{% endif %}
- {% endif %}
- {% if field.help_text %}<div class="helptext">{{ field.help_text|safe }}</div>{% endif %}
- {{ errors }}
- {{ field }}
- {% if field.use_fieldset %}</fieldset>{% endif %}
+ {{ field.as_field_group }}
{% if forloop.last %}
{% for field in hidden_fields %}{{ field }}{% endfor %}
{% endif %}
diff --git a/django/forms/templates/django/forms/field.html b/django/forms/templates/django/forms/field.html
new file mode 100644
index 0000000000..8f26213782
--- /dev/null
+++ b/django/forms/templates/django/forms/field.html
@@ -0,0 +1,10 @@
+{% if field.use_fieldset %}
+ <fieldset>
+ {% if field.label %}{{ field.legend_tag }}{% endif %}
+{% else %}
+ {% if field.label %}{{ field.label_tag }}{% endif %}
+{% endif %}
+{% if field.help_text %}<div class="helptext">{{ field.help_text|safe }}</div>{% endif %}
+{{ field.errors }}
+{{ field }}
+{% if field.use_fieldset %}</fieldset>{% endif %}
diff --git a/django/forms/utils.py b/django/forms/utils.py
index e0888b6e85..f4fbf3e241 100644
--- a/django/forms/utils.py
+++ b/django/forms/utils.py
@@ -58,6 +58,29 @@ class RenderableMixin:
__html__ = render
+class RenderableFieldMixin(RenderableMixin):
+ def as_field_group(self):
+ return self.render()
+
+ def as_hidden(self):
+ raise NotImplementedError(
+ "Subclasses of RenderableFieldMixin must provide an as_hidden() method."
+ )
+
+ def as_widget(self):
+ raise NotImplementedError(
+ "Subclasses of RenderableFieldMixin must provide an as_widget() method."
+ )
+
+ def __str__(self):
+ """Render this field as an HTML widget."""
+ if self.field.show_hidden_initial:
+ return self.as_widget() + self.as_hidden(only_initial=True)
+ return self.as_widget()
+
+ __html__ = __str__
+
+
class RenderableFormMixin(RenderableMixin):
def as_p(self):
"""Render as <p> elements."""
diff --git a/docs/ref/forms/api.txt b/docs/ref/forms/api.txt
index 12754dbae5..4d4f73d0b4 100644
--- a/docs/ref/forms/api.txt
+++ b/docs/ref/forms/api.txt
@@ -1257,6 +1257,16 @@ Attributes of ``BoundField``
>>> print(f["message"].name)
message
+.. attribute:: BoundField.template_name
+
+ .. versionadded:: 5.0
+
+ The name of the template rendered with :meth:`.BoundField.as_field_group`.
+
+ A property returning the value of the
+ :attr:`~django.forms.Field.template_name` if set otherwise
+ :attr:`~django.forms.renderers.BaseRenderer.field_template_name`.
+
.. attribute:: BoundField.use_fieldset
Returns the value of this BoundField widget's ``use_fieldset`` attribute.
@@ -1281,6 +1291,15 @@ Attributes of ``BoundField``
Methods of ``BoundField``
-------------------------
+.. method:: BoundField.as_field_group()
+
+ .. versionadded:: 5.0
+
+ Renders the field using :meth:`.BoundField.render` with default values
+ which renders the ``BoundField``, including its label, help text and errors
+ using the template's :attr:`~django.forms.Field.template_name` if set
+ otherwise :attr:`~django.forms.renderers.BaseRenderer.field_template_name`
+
.. method:: BoundField.as_hidden(attrs=None, **kwargs)
Returns a string of HTML for representing this as an ``<input type="hidden">``.
@@ -1321,6 +1340,13 @@ Methods of ``BoundField``
>>> f["message"].css_classes("foo bar")
'foo bar required'
+.. method:: BoundField.get_context()
+
+ .. versionadded:: 5.0
+
+ Return the template context for rendering the field. The available context
+ is ``field`` being the instance of the bound field.
+
.. method:: BoundField.label_tag(contents=None, attrs=None, label_suffix=None, tag=None)
Renders a label tag for the form field using the template specified by
@@ -1368,6 +1394,20 @@ Methods of ``BoundField``
checkbox widgets where ``<legend>`` may be more appropriate than a
``<label>``.
+.. method:: BoundField.render(template_name=None, context=None, renderer=None)
+
+ .. versionadded:: 5.0
+
+ The render method is called by ``as_field_group``. All arguments are
+ optional and default to:
+
+ * ``template_name``: :attr:`.BoundField.template_name`
+ * ``context``: Value returned by :meth:`.BoundField.get_context`
+ * ``renderer``: Value returned by :attr:`.Form.default_renderer`
+
+ By passing ``template_name`` you can customize the template used for just a
+ single call.
+
.. method:: BoundField.value()
Use this method to render the raw value of this field as it would be rendered
diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt
index 7d975a74d5..476075b936 100644
--- a/docs/ref/forms/fields.txt
+++ b/docs/ref/forms/fields.txt
@@ -337,6 +337,19 @@ using the ``disabled`` HTML attribute so that it won't be editable by users.
Even if a user tampers with the field's value submitted to the server, it will
be ignored in favor of the value from the form's initial data.
+``template_name``
+-----------------
+
+.. attribute:: Field.template_name
+
+.. versionadded:: 5.0
+
+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
+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`.
+
Checking if the field data has changed
======================================
diff --git a/docs/ref/forms/renderers.txt b/docs/ref/forms/renderers.txt
index 6b4eb95cd7..053b5998c1 100644
--- a/docs/ref/forms/renderers.txt
+++ b/docs/ref/forms/renderers.txt
@@ -59,6 +59,14 @@ should return a rendered templates (as a string) or raise
Defaults to ``"django/forms/formsets/div.html"`` template.
+ .. attribute:: field_template_name
+
+ .. versionadded:: 5.0
+
+ The default name of the template used to render a ``BoundField``.
+
+ Defaults to ``"django/forms/field.html"``
+
.. method:: get_template(template_name)
Subclasses must implement this method with the appropriate template
@@ -162,6 +170,16 @@ forms receive a dictionary with the following values:
* ``hidden_fields``: All hidden bound fields.
* ``errors``: All non field related or hidden field related form errors.
+Context available in field templates
+====================================
+
+.. versionadded:: 5.0
+
+Field templates receive a context from :meth:`.BoundField.get_context`. By
+default, fields receive a dictionary with the following values:
+
+* ``field``: The :class:`~django.forms.BoundField`.
+
Context available in widget templates
=====================================
@@ -201,6 +219,19 @@ To override form templates, you must use the :class:`TemplatesSetting`
renderer. Then overriding widget templates works :doc:`the same as
</howto/overriding-templates>` overriding any other template in your project.
+.. _overriding-built-in-field-templates:
+
+Overriding built-in field templates
+===================================
+
+.. versionadded:: 5.0
+
+:attr:`.Field.template_name`
+
+To override field templates, you must use the :class:`TemplatesSetting`
+renderer. Then overriding field templates works :doc:`the same as
+</howto/overriding-templates>` overriding any other template in your project.
+
.. _overriding-built-in-widget-templates:
Overriding built-in widget templates
diff --git a/docs/releases/5.0.txt b/docs/releases/5.0.txt
index b2e8b9d48b..08de2a3740 100644
--- a/docs/releases/5.0.txt
+++ b/docs/releases/5.0.txt
@@ -45,6 +45,69 @@ toggled on via the UI. This behavior can be changed via the new
:attr:`.ModelAdmin.show_facets` attribute. For more information see
:ref:`facet-filters`.
+Simplified templates for form field rendering
+---------------------------------------------
+
+Django 5.0 introduces the concept of a field group, and field group templates.
+This simplifies rendering of the related elements of a Django form field such
+as its label, widget, help text, and errors.
+
+For example, the template below:
+
+.. code-block:: html+django
+
+ <form>
+ ...
+ <div>
+ {{ form.name.label }}
+ {% if form.name.help_text %}
+ <div class="helptext">{{ form.name.help_text|safe }}</div>
+ {% endif %}
+ {{ form.name.errors }}
+ {{ form.name }}
+ <div class="row">
+ <div class="col">
+ {{ form.email.label }}
+ {% if form.email.help_text %}
+ <div class="helptext">{{ form.email.help_text|safe }}</div>
+ {% endif %}
+ {{ form.email.errors }}
+ {{ form.email }}
+ </div>
+ <div class="col">
+ {{ form.password.label }}
+ {% if form.password.help_text %}
+ <div class="helptext">{{ form.password.help_text|safe }}</div>
+ {% endif %}
+ {{ form.password.errors }}
+ {{ form.password }}
+ </div>
+ </div>
+ </div>
+ ...
+ </form>
+
+Can now be simplified to:
+
+.. code-block:: html+django
+
+ <form>
+ ...
+ <div>
+ {{ form.name.as_field_group }}
+ <div class="row">
+ <div class="col">{{ form.email.as_field_group }}</div>
+ <div class="col">{{ form.password.as_field_group }}</div>
+ </div>
+ </div>
+ ...
+ </form>
+
+:meth:`~django.forms.BoundField.as_field_group` renders fields with the
+``"django/forms/field.html"`` template by default and can be customized on a
+per-project, per-field, or per-request basis. See
+:ref:`reusable-field-group-templates`.
+
Minor features
--------------
diff --git a/docs/topics/forms/index.txt b/docs/topics/forms/index.txt
index fec2b03251..27ea496ca6 100644
--- a/docs/topics/forms/index.txt
+++ b/docs/topics/forms/index.txt
@@ -559,13 +559,73 @@ the :meth:`.Form.render`. Here's an example of this being used in a view::
See :ref:`ref-forms-api-outputting-html` for more details.
+.. _reusable-field-group-templates:
+
+Reusable field group templates
+------------------------------
+
+.. versionadded:: 5.0
+
+Each field is available as an attribute of the form, using
+``{{form.name_of_field }}`` in a template. A field has a
+:meth:`~django.forms.BoundField.as_field_group` method which renders the
+related elements of the field as a group, its label, widget, errors, and help
+text.
+
+This allows generic templates to be written that arrange fields elements in the
+required layout. For example:
+
+.. code-block:: html+django
+
+ {{ form.non_field_errors }}
+ <div class="fieldWrapper">
+ {{ form.subject.as_field_group }}
+ </div>
+ <div class="fieldWrapper">
+ {{ form.message.as_field_group }}
+ </div>
+ <div class="fieldWrapper">
+ {{ form.sender.as_field_group }}
+ </div>
+ <div class="fieldWrapper">
+ {{ form.cc_myself.as_field_group }}
+ </div>
+
+By default Django uses the ``"django/forms/field.html"`` template which is
+designed for use with the default ``"django/forms/div.html"`` form style.
+
+The default template can be customized by by setting
+:attr:`~django.forms.renderers.BaseRenderer.field_template_name` in your
+project-level :setting:`FORM_RENDERER`::
+
+ from django.forms.renderers import TemplatesSetting
+
+
+ class CustomFormRenderer(TemplatesSetting):
+ field_template_name = "field_snippet.html"
+
+… or on a single field::
+
+ class MyForm(forms.Form):
+ subject = forms.CharField(template_name="my_custom_template.html")
+ ...
+
+… or on a per-request basis by calling
+:meth:`.BoundField.render` and supplying a template name::
+
+ def index(request):
+ form = ContactForm()
+ subject = form["subject"]
+ context = {"subject": subject.render("my_custom_template.html")}
+ return render(request, "index.html", context)
+
Rendering fields manually
-------------------------
-We don't have to let Django unpack the form's fields; we can do it manually if
-we like (allowing us to reorder the fields, for example). Each field is
-available as an attribute of the form using ``{{ form.name_of_field }}``, and
-in a Django template, will be rendered appropriately. For example:
+More fine grained control over field rendering is also possible. Likely this
+will be in a custom field template, to allow the template to be written once
+and reused for each field. However, it can also be directly accessed from the
+field attribute on the form. For example:
.. code-block:: html+django
diff --git a/tests/forms_tests/templates/forms_tests/custom_field.html b/tests/forms_tests/templates/forms_tests/custom_field.html
new file mode 100644
index 0000000000..5d19c9ed49
--- /dev/null
+++ b/tests/forms_tests/templates/forms_tests/custom_field.html
@@ -0,0 +1,3 @@
+{{ field.label_tag }}
+<p>Custom Field<p>
+{{ field }}
diff --git a/tests/forms_tests/tests/test_forms.py b/tests/forms_tests/tests/test_forms.py
index 14e3755b68..5563dc35fd 100644
--- a/tests/forms_tests/tests/test_forms.py
+++ b/tests/forms_tests/tests/test_forms.py
@@ -4602,6 +4602,7 @@ class Jinja2FormsTestCase(FormsTestCase):
class CustomRenderer(DjangoTemplates):
form_template_name = "forms_tests/form_snippet.html"
+ field_template_name = "forms_tests/custom_field.html"
class RendererTests(SimpleTestCase):
@@ -5009,6 +5010,28 @@ class TemplateTests(SimpleTestCase):
"('username', 'adrian')]",
)
+ def test_custom_field_template(self):
+ class MyForm(Form):
+ first_name = CharField(template_name="forms_tests/custom_field.html")
+
+ f = MyForm()
+ self.assertHTMLEqual(
+ f.render(),
+ '<div><label for="id_first_name">First name:</label><p>Custom Field<p>'
+ '<input type="text" name="first_name" required id="id_first_name"></div>',
+ )
+
+ def test_custom_field_render_template(self):
+ class MyForm(Form):
+ first_name = CharField()
+
+ f = MyForm()
+ self.assertHTMLEqual(
+ f["first_name"].render(template_name="forms_tests/custom_field.html"),
+ '<label for="id_first_name">First name:</label><p>Custom Field<p>'
+ '<input type="text" name="first_name" required id="id_first_name">',
+ )
+
class OverrideTests(SimpleTestCase):
@override_settings(FORM_RENDERER="forms_tests.tests.test_forms.CustomRenderer")
@@ -5026,6 +5049,22 @@ class OverrideTests(SimpleTestCase):
self.assertHTMLEqual(html, expected)
get_default_renderer.cache_clear()
+ @override_settings(FORM_RENDERER="forms_tests.tests.test_forms.CustomRenderer")
+ def test_custom_renderer_field_template_name(self):
+ class Person(Form):
+ first_name = CharField()
+
+ get_default_renderer.cache_clear()
+ t = Template("{{ form.first_name.as_field_group }}")
+ html = t.render(Context({"form": Person()}))
+ expected = """
+ <label for="id_first_name">First name:</label>
+ <p>Custom Field<p>
+ <input type="text" name="first_name" required id="id_first_name">
+ """
+ self.assertHTMLEqual(html, expected)
+ get_default_renderer.cache_clear()
+
def test_per_form_template_name(self):
class Person(Form):
first_name = CharField()
diff --git a/tests/forms_tests/tests/test_utils.py b/tests/forms_tests/tests/test_utils.py
index 2e5672f93c..f9a5d4c82a 100644
--- a/tests/forms_tests/tests/test_utils.py
+++ b/tests/forms_tests/tests/test_utils.py
@@ -5,6 +5,7 @@ from django.core.exceptions import ValidationError
from django.forms.utils import (
ErrorDict,
ErrorList,
+ RenderableFieldMixin,
RenderableMixin,
flatatt,
pretty_name,
@@ -258,6 +259,18 @@ class FormsUtilsTestCase(SimpleTestCase):
with self.assertRaisesMessage(NotImplementedError, msg):
mixin.get_context()
+ def test_field_mixin_as_hidden_must_be_implemented(self):
+ mixin = RenderableFieldMixin()
+ msg = "Subclasses of RenderableFieldMixin must provide an as_hidden() method."
+ with self.assertRaisesMessage(NotImplementedError, msg):
+ mixin.as_hidden()
+
+ def test_field_mixin_as_widget_must_be_implemented(self):
+ mixin = RenderableFieldMixin()
+ msg = "Subclasses of RenderableFieldMixin must provide an as_widget() method."
+ with self.assertRaisesMessage(NotImplementedError, msg):
+ mixin.as_widget()
+
def test_pretty_name(self):
self.assertEqual(pretty_name("john_doe"), "John doe")
self.assertEqual(pretty_name(None), "")