From cad376f844c7bdeeee7607a7c0ea8ae52061309b Mon Sep 17 00:00:00 2001 From: David Smith Date: Wed, 2 Nov 2022 20:13:16 +0000 Subject: [PATCH] Fixed #34077 -- Added form field rendering. --- django/forms/boundfield.py | 19 +++--- django/forms/fields.py | 2 + django/forms/jinja2/django/forms/div.html | 11 +-- django/forms/jinja2/django/forms/field.html | 10 +++ django/forms/renderers.py | 1 + django/forms/templates/django/forms/div.html | 11 +-- .../forms/templates/django/forms/field.html | 10 +++ django/forms/utils.py | 23 +++++++ docs/ref/forms/api.txt | 40 +++++++++++ docs/ref/forms/fields.txt | 13 ++++ docs/ref/forms/renderers.txt | 31 +++++++++ docs/releases/5.0.txt | 63 +++++++++++++++++ docs/topics/forms/index.txt | 68 +++++++++++++++++-- .../templates/forms_tests/custom_field.html | 3 + tests/forms_tests/tests/test_forms.py | 39 +++++++++++ tests/forms_tests/tests/test_utils.py | 13 ++++ 16 files changed, 324 insertions(+), 33 deletions(-) create mode 100644 django/forms/jinja2/django/forms/field.html create mode 100644 django/forms/templates/django/forms/field.html create mode 100644 tests/forms_tests/templates/forms_tests/custom_field.html 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 %} - {% if field.use_fieldset %} -
- {% if field.label %}{{ field.legend_tag() }}{% endif %} - {% else %} - {% if field.label %}{{ field.label_tag() }}{% endif %} - {% endif %} - {% if field.help_text %}
{{ field.help_text|safe }}
{% endif %} - {{ errors }} - {{ field }} - {% if field.use_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 %} +
+ {% if field.label %}{{ field.legend_tag() }}{% endif %} +{% else %} + {% if field.label %}{{ field.label_tag() }}{% endif %} +{% endif %} +{% if field.help_text %}
{{ field.help_text|safe }}
{% endif %} +{{ field.errors }} +{{ field }} +{% if field.use_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 %} - {% if field.use_fieldset %} -
- {% if field.label %}{{ field.legend_tag }}{% endif %} - {% else %} - {% if field.label %}{{ field.label_tag }}{% endif %} - {% endif %} - {% if field.help_text %}
{{ field.help_text|safe }}
{% endif %} - {{ errors }} - {{ field }} - {% if field.use_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 %} +
+ {% if field.label %}{{ field.legend_tag }}{% endif %} +{% else %} + {% if field.label %}{{ field.label_tag }}{% endif %} +{% endif %} +{% if field.help_text %}
{{ field.help_text|safe }}
{% endif %} +{{ field.errors }} +{{ field }} +{% if field.use_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

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 ````. @@ -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 ```` may be more appropriate than a ``

+ ... +
+ {{ form.name.label }} + {% if form.name.help_text %} +
{{ form.name.help_text|safe }}
+ {% endif %} + {{ form.name.errors }} + {{ form.name }} +
+
+ {{ form.email.label }} + {% if form.email.help_text %} +
{{ form.email.help_text|safe }}
+ {% endif %} + {{ form.email.errors }} + {{ form.email }} +
+
+ {{ form.password.label }} + {% if form.password.help_text %} +
{{ form.password.help_text|safe }}
+ {% endif %} + {{ form.password.errors }} + {{ form.password }} +
+
+
+ ... +
+ +Can now be simplified to: + +.. code-block:: html+django + +
+ ... +
+ {{ form.name.as_field_group }} +
+
{{ form.email.as_field_group }}
+
{{ form.password.as_field_group }}
+
+
+ ... +
+ +: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 }} +
+ {{ form.subject.as_field_group }} +
+
+ {{ form.message.as_field_group }} +
+
+ {{ form.sender.as_field_group }} +
+
+ {{ form.cc_myself.as_field_group }} +
+ +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 }} +

Custom Field

+{{ 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(), + '

Custom Field

' + '

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

Custom Field

' + '', + ) + 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 = """ + +

Custom Field

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