Browse Source

Fixed #35886 -- Added support for object-based form media script assets.

Johannes Maron 4 months ago
parent
commit
989329344a
4 changed files with 211 additions and 94 deletions
  1. 49 1
      django/forms/widgets.py
  2. 4 0
      docs/releases/5.2.txt
  3. 46 17
      docs/topics/forms/media.txt
  4. 112 76
      tests/forms_tests/tests/test_media.py

+ 49 - 1
django/forms/widgets.py

@@ -9,7 +9,7 @@ from collections import defaultdict
 from graphlib import CycleError, TopologicalSorter
 from itertools import chain
 
-from django.forms.utils import to_current_timezone
+from django.forms.utils import flatatt, to_current_timezone
 from django.templatetags.static import static
 from django.utils import formats
 from django.utils.choices import normalize_choices
@@ -23,6 +23,7 @@ from django.utils.translation import gettext_lazy as _
 from .renderers import get_default_renderer
 
 __all__ = (
+    "Script",
     "Media",
     "MediaDefiningClass",
     "Widget",
@@ -61,6 +62,53 @@ class MediaOrderConflictWarning(RuntimeWarning):
     pass
 
 
+@html_safe
+class MediaAsset:
+    element_template = "{path}"
+
+    def __init__(self, path, **attributes):
+        self._path = path
+        self.attributes = attributes
+
+    def __eq__(self, other):
+        # Compare the path only, to ensure performant comparison in Media.merge.
+        return (self.__class__ is other.__class__ and self.path == other.path) or (
+            isinstance(other, str) and self._path == other
+        )
+
+    def __hash__(self):
+        # Hash the path only, to ensure performant comparison in Media.merge.
+        return hash(self._path)
+
+    def __str__(self):
+        return format_html(
+            self.element_template,
+            path=self.path,
+            attributes=flatatt(self.attributes),
+        )
+
+    def __repr__(self):
+        return f"{type(self).__qualname__}({self._path!r})"
+
+    @property
+    def path(self):
+        """
+        Ensure an absolute path.
+        Relative paths are resolved via the {% static %} template tag.
+        """
+        if self._path.startswith(("http://", "https://", "/")):
+            return self._path
+        return static(self._path)
+
+
+class Script(MediaAsset):
+    element_template = '<script src="{path}"{attributes}></script>'
+
+    def __init__(self, src, **attributes):
+        # Alter the signature to allow src to be passed as a keyword argument.
+        super().__init__(src, **attributes)
+
+
 @html_safe
 class Media:
     def __init__(self, media=None, css=None, js=None):

+ 4 - 0
docs/releases/5.2.txt

@@ -259,6 +259,10 @@ Forms
 * An :attr:`~django.forms.BoundField.aria_describedby` property is added to
   ``BoundField`` to ease use of this HTML attribute in templates.
 
+* The new asset object :class:`~django.forms.Script` is available for adding
+  custom HTML-attributes to JavaScript in form media. See
+  :ref:`paths as objects <form-media-asset-objects>` for more details.
+
 Generic Views
 ~~~~~~~~~~~~~
 

+ 46 - 17
docs/topics/forms/media.txt

@@ -2,6 +2,8 @@
 Form Assets (the ``Media`` class)
 =================================
 
+.. currentmodule:: django.forms
+
 Rendering an attractive and easy-to-use web form requires more than just
 HTML - it also requires CSS stylesheets, and if you want to use fancy widgets,
 you may also need to include some JavaScript on each page. The exact
@@ -130,6 +132,24 @@ A tuple describing the required JavaScript files. See :ref:`the
 section on paths <form-asset-paths>` for details of how to specify
 paths to these files.
 
+``Script`` objects
+~~~~~~~~~~~~~~~~~~
+
+.. versionadded:: 5.2
+
+.. class:: Script(src, **attributes)
+
+    Represents a script file.
+
+    The first parameter, ``src``, is the string path to the script file. See
+    :ref:`the section on paths <form-asset-paths>` for details on how to
+    specify paths to these files.
+
+    The optional keyword arguments, ``**attributes``, are HTML attributes that
+    are set on the rendered ``<script>`` tag.
+
+    See :ref:`form-media-asset-objects` for usage examples.
+
 ``extend``
 ----------
 
@@ -271,29 +291,38 @@ Or if :mod:`~django.contrib.staticfiles` is configured using the
     <script src="https://static.example.com/animations.27e20196a850.js"></script>
     <script src="https://othersite.com/actions.js"></script>
 
+.. _form-media-asset-objects:
+
 Paths as objects
 ----------------
 
-Asset paths may also be given as hashable objects implementing an
-``__html__()`` method. The ``__html__()`` method is typically added using the
-:func:`~django.utils.html.html_safe` decorator. The object is responsible for
-outputting the complete HTML ``<script>`` or ``<link>`` tag content:
+Assets may also be object-based, using :class:`.Script`.
+Furthermore, these allow you to pass custom HTML attributes::
 
-.. code-block:: pycon
+    class Media:
+        js = [
+            Script(
+                "https://cdn.example.com/something.min.js",
+                **{
+                    "crossorigin": "anonymous",
+                    "async": True,
+                },
+            ),
+        ]
+
+If this Media definition were to be rendered, it would become the following
+HTML:
 
-    >>> from django import forms
-    >>> from django.utils.html import html_safe
-    >>>
-    >>> @html_safe
-    ... class JSPath:
-    ...     def __str__(self):
-    ...         return '<script src="https://example.org/asset.js" defer>'
-    ...
+.. code-block:: html+django
 
-    >>> class SomeWidget(forms.TextInput):
-    ...     class Media:
-    ...         js = [JSPath()]
-    ...
+    <script src="https://cdn.example.com/something.min.js"
+            crossorigin="anonymous"
+            async>
+    </script>
+
+.. versionchanged:: 5.2
+
+    The object class ``Script`` was added.
 
 ``Media`` objects
 =================

+ 112 - 76
tests/forms_tests/tests/test_media.py

@@ -1,8 +1,94 @@
 from django.forms import CharField, Form, Media, MultiWidget, TextInput
+from django.forms.widgets import MediaAsset, Script
 from django.template import Context, Template
-from django.templatetags.static import static
 from django.test import SimpleTestCase, override_settings
-from django.utils.html import format_html, html_safe
+from django.utils.html import html_safe
+
+
+class CSS(MediaAsset):
+    element_template = '<link href="{path}"{attributes}>'
+
+    def __init__(self, href, **attributes):
+        super().__init__(href, **attributes)
+        self.attributes["rel"] = "stylesheet"
+
+
+@override_settings(STATIC_URL="http://media.example.com/static/")
+class MediaAssetTestCase(SimpleTestCase):
+    def test_init(self):
+        attributes = {"media": "all", "is": "magic-css"}
+        asset = MediaAsset("path/to/css", **attributes)
+        self.assertEqual(asset._path, "path/to/css")
+        self.assertEqual(asset.attributes, attributes)
+        self.assertIsNot(asset.attributes, attributes)
+
+    def test_eq(self):
+        self.assertEqual(MediaAsset("path/to/css"), MediaAsset("path/to/css"))
+        self.assertEqual(MediaAsset("path/to/css"), "path/to/css")
+        self.assertEqual(
+            MediaAsset("path/to/css", media="all"), MediaAsset("path/to/css")
+        )
+
+        self.assertNotEqual(MediaAsset("path/to/css"), MediaAsset("path/to/other.css"))
+        self.assertNotEqual(MediaAsset("path/to/css"), "path/to/other.css")
+        self.assertNotEqual(MediaAsset("path/to/css", media="all"), CSS("path/to/css"))
+
+    def test_hash(self):
+        self.assertEqual(hash(MediaAsset("path/to/css")), hash("path/to/css"))
+        self.assertEqual(
+            hash(MediaAsset("path/to/css")), hash(MediaAsset("path/to/css"))
+        )
+
+    def test_str(self):
+        self.assertEqual(
+            str(MediaAsset("path/to/css")),
+            "http://media.example.com/static/path/to/css",
+        )
+        self.assertEqual(
+            str(MediaAsset("http://media.other.com/path/to/css")),
+            "http://media.other.com/path/to/css",
+        )
+
+    def test_repr(self):
+        self.assertEqual(repr(MediaAsset("path/to/css")), "MediaAsset('path/to/css')")
+        self.assertEqual(
+            repr(MediaAsset("http://media.other.com/path/to/css")),
+            "MediaAsset('http://media.other.com/path/to/css')",
+        )
+
+    def test_path(self):
+        asset = MediaAsset("path/to/css")
+        self.assertEqual(asset.path, "http://media.example.com/static/path/to/css")
+
+        asset = MediaAsset("http://media.other.com/path/to/css")
+        self.assertEqual(asset.path, "http://media.other.com/path/to/css")
+
+        asset = MediaAsset("https://secure.other.com/path/to/css")
+        self.assertEqual(asset.path, "https://secure.other.com/path/to/css")
+
+        asset = MediaAsset("/absolute/path/to/css")
+        self.assertEqual(asset.path, "/absolute/path/to/css")
+
+        asset = MediaAsset("//absolute/path/to/css")
+        self.assertEqual(asset.path, "//absolute/path/to/css")
+
+
+@override_settings(STATIC_URL="http://media.example.com/static/")
+class ScriptTestCase(SimpleTestCase):
+    def test_init_with_src_kwarg(self):
+        self.assertEqual(
+            Script(src="path/to/js").path, "http://media.example.com/static/path/to/js"
+        )
+
+    def test_str(self):
+        self.assertHTMLEqual(
+            str(Script("path/to/js")),
+            '<script src="http://media.example.com/static/path/to/js"></script>',
+        )
+        self.assertHTMLEqual(
+            str(Script("path/to/js", **{"async": True, "deferred": False})),
+            '<script src="http://media.example.com/static/path/to/js" async></script>',
+        )
 
 
 @override_settings(
@@ -714,63 +800,6 @@ class FormsMediaTestCase(SimpleTestCase):
         self.assertEqual(merged._js_lists, [["a"]])
 
 
-@html_safe
-class Asset:
-    def __init__(self, path):
-        self.path = path
-
-    def __eq__(self, other):
-        return (self.__class__ == other.__class__ and self.path == other.path) or (
-            other.__class__ == str and self.path == other
-        )
-
-    def __hash__(self):
-        return hash(self.path)
-
-    def __str__(self):
-        return self.absolute_path(self.path)
-
-    def absolute_path(self, path):
-        """
-        Given a relative or absolute path to a static asset, return an absolute
-        path. An absolute path will be returned unchanged while a relative path
-        will be passed to django.templatetags.static.static().
-        """
-        if path.startswith(("http://", "https://", "/")):
-            return path
-        return static(path)
-
-    def __repr__(self):
-        return f"{self.path!r}"
-
-
-class CSS(Asset):
-    def __init__(self, path, medium):
-        super().__init__(path)
-        self.medium = medium
-
-    def __str__(self):
-        path = super().__str__()
-        return format_html(
-            '<link href="{}" media="{}" rel="stylesheet">',
-            self.absolute_path(path),
-            self.medium,
-        )
-
-
-class JS(Asset):
-    def __init__(self, path, integrity=None):
-        super().__init__(path)
-        self.integrity = integrity or ""
-
-    def __str__(self, integrity=None):
-        path = super().__str__()
-        template = '<script src="{}"%s></script>' % (
-            ' integrity="{}"' if self.integrity else "{}"
-        )
-        return format_html(template, self.absolute_path(path), self.integrity)
-
-
 @override_settings(
     STATIC_URL="http://media.example.com/static/",
 )
@@ -779,17 +808,22 @@ class FormsMediaObjectTestCase(SimpleTestCase):
 
     def test_construction(self):
         m = Media(
-            css={"all": (CSS("path/to/css1", "all"), CSS("/path/to/css2", "all"))},
+            css={
+                "all": (
+                    CSS("path/to/css1", media="all"),
+                    CSS("/path/to/css2", media="all"),
+                )
+            },
             js=(
-                JS("/path/to/js1"),
-                JS("http://media.other.com/path/to/js2"),
-                JS(
+                Script("/path/to/js1"),
+                Script("http://media.other.com/path/to/js2"),
+                Script(
                     "https://secure.other.com/path/to/js3",
                     integrity="9d947b87fdeb25030d56d01f7aa75800",
                 ),
             ),
         )
-        self.assertEqual(
+        self.assertHTMLEqual(
             str(m),
             '<link href="http://media.example.com/static/path/to/css1" media="all" '
             'rel="stylesheet">\n'
@@ -801,9 +835,9 @@ class FormsMediaObjectTestCase(SimpleTestCase):
         )
         self.assertEqual(
             repr(m),
-            "Media(css={'all': ['path/to/css1', '/path/to/css2']}, "
-            "js=['/path/to/js1', 'http://media.other.com/path/to/js2', "
-            "'https://secure.other.com/path/to/js3'])",
+            "Media(css={'all': [CSS('path/to/css1'), CSS('/path/to/css2')]}, "
+            "js=[Script('/path/to/js1'), Script('http://media.other.com/path/to/js2'), "
+            "Script('https://secure.other.com/path/to/js3')])",
         )
 
     def test_simplest_class(self):
@@ -823,22 +857,24 @@ class FormsMediaObjectTestCase(SimpleTestCase):
     def test_combine_media(self):
         class MyWidget1(TextInput):
             class Media:
-                css = {"all": (CSS("path/to/css1", "all"), "/path/to/css2")}
+                css = {"all": (CSS("path/to/css1", media="all"), "/path/to/css2")}
                 js = (
                     "/path/to/js1",
                     "http://media.other.com/path/to/js2",
                     "https://secure.other.com/path/to/js3",
-                    JS("/path/to/js4", integrity="9d947b87fdeb25030d56d01f7aa75800"),
+                    Script(
+                        "/path/to/js4", integrity="9d947b87fdeb25030d56d01f7aa75800"
+                    ),
                 )
 
         class MyWidget2(TextInput):
             class Media:
-                css = {"all": (CSS("/path/to/css2", "all"), "/path/to/css3")}
-                js = (JS("/path/to/js1"), "/path/to/js4")
+                css = {"all": (CSS("/path/to/css2", media="all"), "/path/to/css3")}
+                js = (Script("/path/to/js1"), "/path/to/js4")
 
         w1 = MyWidget1()
         w2 = MyWidget2()
-        self.assertEqual(
+        self.assertHTMLEqual(
             str(w1.media + w2.media),
             '<link href="http://media.example.com/static/path/to/css1" media="all" '
             'rel="stylesheet">\n'
@@ -857,14 +893,14 @@ class FormsMediaObjectTestCase(SimpleTestCase):
         media = Media(
             css={
                 "all": (
-                    CSS("/path/to/css1", "all"),
-                    CSS("/path/to/css1", "all"),
+                    CSS("/path/to/css1", media="all"),
+                    CSS("/path/to/css1", media="all"),
                     "/path/to/css1",
                 )
             },
-            js=(JS("/path/to/js1"), JS("/path/to/js1"), "/path/to/js1"),
+            js=(Script("/path/to/js1"), Script("/path/to/js1"), "/path/to/js1"),
         )
-        self.assertEqual(
+        self.assertHTMLEqual(
             str(media),
             '<link href="/path/to/css1" media="all" rel="stylesheet">\n'
             '<script src="/path/to/js1"></script>',