Selaa lähdekoodia

Fixed #34077 -- Added form field rendering.

David Smith 2 vuotta sitten
vanhempi
commit
cad376f844

+ 10 - 9
django/forms/boundfield.py

@@ -1,7 +1,7 @@
 import re
 import re
 
 
 from django.core.exceptions import ValidationError
 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.forms.widgets import MultiWidget, Textarea, TextInput
 from django.utils.functional import cached_property
 from django.utils.functional import cached_property
 from django.utils.html import format_html, html_safe
 from django.utils.html import format_html, html_safe
@@ -10,8 +10,7 @@ from django.utils.translation import gettext_lazy as _
 __all__ = ("BoundField",)
 __all__ = ("BoundField",)
 
 
 
 
-@html_safe
-class BoundField:
+class BoundField(RenderableFieldMixin):
     "A Field plus data"
     "A Field plus data"
 
 
     def __init__(self, form, field, name):
     def __init__(self, form, field, name):
@@ -26,12 +25,7 @@ class BoundField:
         else:
         else:
             self.label = self.field.label
             self.label = self.field.label
         self.help_text = field.help_text or ""
         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
     @cached_property
     def subwidgets(self):
     def subwidgets(self):
@@ -81,6 +75,13 @@ class BoundField:
             self.name, self.form.error_class(renderer=self.form.renderer)
             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):
     def as_widget(self, widget=None, attrs=None, only_initial=False):
         """
         """
         Render the field by rendering the passed widget, adding any HTML
         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,
         localize=False,
         disabled=False,
         disabled=False,
         label_suffix=None,
         label_suffix=None,
+        template_name=None,
     ):
     ):
         # required -- Boolean that specifies whether the field is required.
         # required -- Boolean that specifies whether the field is required.
         #             True by default.
         #             True by default.
@@ -164,6 +165,7 @@ class Field:
         self.error_messages = messages
         self.error_messages = messages
 
 
         self.validators = [*self.default_validators, *validators]
         self.validators = [*self.default_validators, *validators]
+        self.template_name = template_name
 
 
         super().__init__()
         super().__init__()
 
 

+ 1 - 10
django/forms/jinja2/django/forms/div.html

@@ -4,16 +4,7 @@
 {% endif %}
 {% endif %}
 {% for field, errors in fields %}
 {% for field, errors in fields %}
   <div{% set classes = field.css_classes() %}{% if classes %} class="{{ classes }}"{% endif %}>
   <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 %}
     {% if loop.last %}
       {% for field in hidden_fields %}{{ field }}{% endfor %}
       {% for field in hidden_fields %}{{ field }}{% endfor %}
     {% endif %}
     {% 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:
 class BaseRenderer:
     form_template_name = "django/forms/div.html"
     form_template_name = "django/forms/div.html"
     formset_template_name = "django/forms/formsets/div.html"
     formset_template_name = "django/forms/formsets/div.html"
+    field_template_name = "django/forms/field.html"
 
 
     def get_template(self, template_name):
     def get_template(self, template_name):
         raise NotImplementedError("subclasses must implement get_template()")
         raise NotImplementedError("subclasses must implement get_template()")

+ 1 - 10
django/forms/templates/django/forms/div.html

@@ -4,16 +4,7 @@
 {% endif %}
 {% endif %}
 {% for field, errors in fields %}
 {% for field, errors in fields %}
   <div{% with classes=field.css_classes %}{% if classes %} class="{{ classes }}"{% endif %}{% endwith %}>
   <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 %}
     {% if forloop.last %}
       {% for field in hidden_fields %}{{ field }}{% endfor %}
       {% for field in hidden_fields %}{{ field }}{% endfor %}
     {% endif %}
     {% 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
     __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):
 class RenderableFormMixin(RenderableMixin):
     def as_p(self):
     def as_p(self):
         """Render as <p> elements."""
         """Render as <p> elements."""

+ 40 - 0
docs/ref/forms/api.txt

@@ -1257,6 +1257,16 @@ Attributes of ``BoundField``
         >>> print(f["message"].name)
         >>> print(f["message"].name)
         message
         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
 .. attribute:: BoundField.use_fieldset
 
 
     Returns the value of this BoundField widget's ``use_fieldset`` attribute.
     Returns the value of this BoundField widget's ``use_fieldset`` attribute.
@@ -1281,6 +1291,15 @@ Attributes of ``BoundField``
 Methods 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)
 .. method:: BoundField.as_hidden(attrs=None, **kwargs)
 
 
     Returns a string of HTML for representing this as an ``<input type="hidden">``.
     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")
         >>> f["message"].css_classes("foo bar")
         'foo bar required'
         '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)
 .. 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
     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
     checkbox widgets where ``<legend>`` may be more appropriate than a
     ``<label>``.
     ``<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()
 .. method:: BoundField.value()
 
 
     Use this method to render the raw value of this field as it would be rendered
     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
 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.
 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
 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.
         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)
     .. method:: get_template(template_name)
 
 
         Subclasses must implement this method with the appropriate template
         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.
 * ``hidden_fields``: All hidden bound fields.
 * ``errors``: All non field related or hidden field related form errors.
 * ``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
 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
 renderer. Then overriding widget templates works :doc:`the same as
 </howto/overriding-templates>` overriding any other template in your project.
 </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:
 
 
 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
 :attr:`.ModelAdmin.show_facets` attribute. For more information see
 :ref:`facet-filters`.
 :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
 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.
 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
 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
 .. 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):
 class CustomRenderer(DjangoTemplates):
     form_template_name = "forms_tests/form_snippet.html"
     form_template_name = "forms_tests/form_snippet.html"
+    field_template_name = "forms_tests/custom_field.html"
 
 
 
 
 class RendererTests(SimpleTestCase):
 class RendererTests(SimpleTestCase):
@@ -5009,6 +5010,28 @@ class TemplateTests(SimpleTestCase):
             "('username', 'adrian')]",
             "('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):
 class OverrideTests(SimpleTestCase):
     @override_settings(FORM_RENDERER="forms_tests.tests.test_forms.CustomRenderer")
     @override_settings(FORM_RENDERER="forms_tests.tests.test_forms.CustomRenderer")
@@ -5026,6 +5049,22 @@ class OverrideTests(SimpleTestCase):
         self.assertHTMLEqual(html, expected)
         self.assertHTMLEqual(html, expected)
         get_default_renderer.cache_clear()
         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):
     def test_per_form_template_name(self):
         class Person(Form):
         class Person(Form):
             first_name = CharField()
             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 (
 from django.forms.utils import (
     ErrorDict,
     ErrorDict,
     ErrorList,
     ErrorList,
+    RenderableFieldMixin,
     RenderableMixin,
     RenderableMixin,
     flatatt,
     flatatt,
     pretty_name,
     pretty_name,
@@ -258,6 +259,18 @@ class FormsUtilsTestCase(SimpleTestCase):
         with self.assertRaisesMessage(NotImplementedError, msg):
         with self.assertRaisesMessage(NotImplementedError, msg):
             mixin.get_context()
             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):
     def test_pretty_name(self):
         self.assertEqual(pretty_name("john_doe"), "John doe")
         self.assertEqual(pretty_name("john_doe"), "John doe")
         self.assertEqual(pretty_name(None), "")
         self.assertEqual(pretty_name(None), "")