Browse Source

Fixed #34077 -- Added form field rendering.

David Smith 2 years ago
parent
commit
cad376f844

+ 10 - 9
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

+ 2 - 0
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__()
 

+ 1 - 10
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 %}

+ 10 - 0
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 %}

+ 1 - 0
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()")

+ 1 - 10
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 %}

+ 10 - 0
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 %}

+ 23 - 0
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."""

+ 40 - 0
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

+ 13 - 0
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
 ======================================
 

+ 31 - 0
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

+ 63 - 0
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
 --------------
 

+ 64 - 4
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
 

+ 3 - 0
tests/forms_tests/templates/forms_tests/custom_field.html

@@ -0,0 +1,3 @@
+{{ field.label_tag }}
+<p>Custom Field<p>
+{{ field }}

+ 39 - 0
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()

+ 13 - 0
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), "")