1
0
mirror of https://github.com/django/django.git synced 2025-06-05 11:39:13 +00:00

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

This commit is contained in:
Johannes Maron 2024-11-07 10:21:25 +01:00 committed by Sarah Boyce
parent b322319f9d
commit 989329344a
4 changed files with 211 additions and 94 deletions

View File

@ -9,7 +9,7 @@ from collections import defaultdict
from graphlib import CycleError, TopologicalSorter from graphlib import CycleError, TopologicalSorter
from itertools import chain 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.templatetags.static import static
from django.utils import formats from django.utils import formats
from django.utils.choices import normalize_choices 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 from .renderers import get_default_renderer
__all__ = ( __all__ = (
"Script",
"Media", "Media",
"MediaDefiningClass", "MediaDefiningClass",
"Widget", "Widget",
@ -61,6 +62,53 @@ class MediaOrderConflictWarning(RuntimeWarning):
pass 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 @html_safe
class Media: class Media:
def __init__(self, media=None, css=None, js=None): def __init__(self, media=None, css=None, js=None):

View File

@ -259,6 +259,10 @@ Forms
* An :attr:`~django.forms.BoundField.aria_describedby` property is added to * An :attr:`~django.forms.BoundField.aria_describedby` property is added to
``BoundField`` to ease use of this HTML attribute in templates. ``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 Generic Views
~~~~~~~~~~~~~ ~~~~~~~~~~~~~

View File

@ -2,6 +2,8 @@
Form Assets (the ``Media`` class) Form Assets (the ``Media`` class)
================================= =================================
.. currentmodule:: django.forms
Rendering an attractive and easy-to-use web form requires more than just 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, 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 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 section on paths <form-asset-paths>` for details of how to specify
paths to these files. 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`` ``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://static.example.com/animations.27e20196a850.js"></script>
<script src="https://othersite.com/actions.js"></script> <script src="https://othersite.com/actions.js"></script>
.. _form-media-asset-objects:
Paths as objects Paths as objects
---------------- ----------------
Asset paths may also be given as hashable objects implementing an Assets may also be object-based, using :class:`.Script`.
``__html__()`` method. The ``__html__()`` method is typically added using the Furthermore, these allow you to pass custom HTML attributes::
:func:`~django.utils.html.html_safe` decorator. The object is responsible for
outputting the complete HTML ``<script>`` or ``<link>`` tag content:
.. code-block:: pycon class Media:
js = [
Script(
"https://cdn.example.com/something.min.js",
**{
"crossorigin": "anonymous",
"async": True,
},
),
]
>>> from django import forms If this Media definition were to be rendered, it would become the following
>>> from django.utils.html import html_safe HTML:
>>>
>>> @html_safe
... class JSPath:
... def __str__(self):
... return '<script src="https://example.org/asset.js" defer>'
...
>>> class SomeWidget(forms.TextInput): .. code-block:: html+django
... 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 ``Media`` objects
================= =================

View File

@ -1,8 +1,94 @@
from django.forms import CharField, Form, Media, MultiWidget, TextInput from django.forms import CharField, Form, Media, MultiWidget, TextInput
from django.forms.widgets import MediaAsset, Script
from django.template import Context, Template from django.template import Context, Template
from django.templatetags.static import static
from django.test import SimpleTestCase, override_settings 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( @override_settings(
@ -714,63 +800,6 @@ class FormsMediaTestCase(SimpleTestCase):
self.assertEqual(merged._js_lists, [["a"]]) 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( @override_settings(
STATIC_URL="http://media.example.com/static/", STATIC_URL="http://media.example.com/static/",
) )
@ -779,17 +808,22 @@ class FormsMediaObjectTestCase(SimpleTestCase):
def test_construction(self): def test_construction(self):
m = Media( 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=(
JS("/path/to/js1"), Script("/path/to/js1"),
JS("http://media.other.com/path/to/js2"), Script("http://media.other.com/path/to/js2"),
JS( Script(
"https://secure.other.com/path/to/js3", "https://secure.other.com/path/to/js3",
integrity="9d947b87fdeb25030d56d01f7aa75800", integrity="9d947b87fdeb25030d56d01f7aa75800",
), ),
), ),
) )
self.assertEqual( self.assertHTMLEqual(
str(m), str(m),
'<link href="http://media.example.com/static/path/to/css1" media="all" ' '<link href="http://media.example.com/static/path/to/css1" media="all" '
'rel="stylesheet">\n' 'rel="stylesheet">\n'
@ -801,9 +835,9 @@ class FormsMediaObjectTestCase(SimpleTestCase):
) )
self.assertEqual( self.assertEqual(
repr(m), repr(m),
"Media(css={'all': ['path/to/css1', '/path/to/css2']}, " "Media(css={'all': [CSS('path/to/css1'), CSS('/path/to/css2')]}, "
"js=['/path/to/js1', 'http://media.other.com/path/to/js2', " "js=[Script('/path/to/js1'), Script('http://media.other.com/path/to/js2'), "
"'https://secure.other.com/path/to/js3'])", "Script('https://secure.other.com/path/to/js3')])",
) )
def test_simplest_class(self): def test_simplest_class(self):
@ -823,22 +857,24 @@ class FormsMediaObjectTestCase(SimpleTestCase):
def test_combine_media(self): def test_combine_media(self):
class MyWidget1(TextInput): class MyWidget1(TextInput):
class Media: class Media:
css = {"all": (CSS("path/to/css1", "all"), "/path/to/css2")} css = {"all": (CSS("path/to/css1", media="all"), "/path/to/css2")}
js = ( js = (
"/path/to/js1", "/path/to/js1",
"http://media.other.com/path/to/js2", "http://media.other.com/path/to/js2",
"https://secure.other.com/path/to/js3", "https://secure.other.com/path/to/js3",
JS("/path/to/js4", integrity="9d947b87fdeb25030d56d01f7aa75800"), Script(
"/path/to/js4", integrity="9d947b87fdeb25030d56d01f7aa75800"
),
) )
class MyWidget2(TextInput): class MyWidget2(TextInput):
class Media: class Media:
css = {"all": (CSS("/path/to/css2", "all"), "/path/to/css3")} css = {"all": (CSS("/path/to/css2", media="all"), "/path/to/css3")}
js = (JS("/path/to/js1"), "/path/to/js4") js = (Script("/path/to/js1"), "/path/to/js4")
w1 = MyWidget1() w1 = MyWidget1()
w2 = MyWidget2() w2 = MyWidget2()
self.assertEqual( self.assertHTMLEqual(
str(w1.media + w2.media), str(w1.media + w2.media),
'<link href="http://media.example.com/static/path/to/css1" media="all" ' '<link href="http://media.example.com/static/path/to/css1" media="all" '
'rel="stylesheet">\n' 'rel="stylesheet">\n'
@ -857,14 +893,14 @@ class FormsMediaObjectTestCase(SimpleTestCase):
media = Media( media = Media(
css={ css={
"all": ( "all": (
CSS("/path/to/css1", "all"), CSS("/path/to/css1", media="all"),
CSS("/path/to/css1", "all"), CSS("/path/to/css1", media="all"),
"/path/to/css1", "/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), str(media),
'<link href="/path/to/css1" media="all" rel="stylesheet">\n' '<link href="/path/to/css1" media="all" rel="stylesheet">\n'
'<script src="/path/to/js1"></script>', '<script src="/path/to/js1"></script>',