Explorar el Código

Replace template components with standalone `laces` library (#11292)

Fixes #11105
Tibor Leupold hace 1 año
padre
commit
10c1e12285

+ 1 - 0
CHANGELOG.txt

@@ -61,6 +61,7 @@ Changelog
  * Maintenance: Update Willow upper bound to 2.x (Dan Braghis)
  * Maintenance: Removed support for Django < 4.2 (Dan Braghis)
  * Maintenance: Refactor page explorer index template to extend generic index template (Sage Abdullah)
+ * Maintenance: Replace template components implementation with standalone `laces` library (Tibor Leupold)
 
 
 5.2.2 (06.12.2023)

+ 1 - 0
docs/releases/6.0.md

@@ -81,6 +81,7 @@ This release adds support for Django 5.0. The support has also been backported t
  * Upgrade `ruff` and replace `black` with `ruff format` (John-Scott Atlakson)
  * Update Willow upper bound to 2.x (Dan Braghis)
  * Refactor page explorer index template to extend generic index template (Sage Abdullah)
+ * Replace template components implementation with standalone `laces` library (Tibor Leupold)
 
 ## Upgrade considerations - removal of deprecated features from Wagtail 4.2 - 5.1
 

+ 1 - 0
setup.py

@@ -36,6 +36,7 @@ install_requires = [
     "openpyxl>=3.0.10,<4.0",
     "anyascii>=0.1.5",
     "telepath>=0.1.1,<1",
+    "laces>=0.1,<0.2",
 ]
 
 # Testing dependencies

+ 7 - 104
wagtail/admin/templatetags/wagtailadmin_tags.py

@@ -19,11 +19,12 @@ from django.urls import reverse
 from django.urls.exceptions import NoReverseMatch
 from django.utils import timezone
 from django.utils.encoding import force_str
-from django.utils.html import avoid_wrapping, conditional_escape, json_script
+from django.utils.html import avoid_wrapping, json_script
 from django.utils.http import urlencode
 from django.utils.safestring import mark_safe
 from django.utils.timesince import timesince
 from django.utils.translation import gettext_lazy as _
+from laces.templatetags.laces import component
 
 from wagtail import hooks
 from wagtail.admin.admin_url_finder import AdminURLFinder
@@ -934,109 +935,6 @@ def resolve_url(url):
         return ""
 
 
-class ComponentNode(template.Node):
-    def __init__(
-        self,
-        component,
-        extra_context=None,
-        isolated_context=False,
-        fallback_render_method=None,
-        target_var=None,
-    ):
-        self.component = component
-        self.extra_context = extra_context or {}
-        self.isolated_context = isolated_context
-        self.fallback_render_method = fallback_render_method
-        self.target_var = target_var
-
-    def render(self, context: Context) -> str:
-        # Render a component by calling its render_html method, passing request and context from the
-        # calling template.
-        # If fallback_render_method is true, objects without a render_html method will have render()
-        # called instead (with no arguments) - this is to provide deprecation path for things that have
-        # been newly upgraded to use the component pattern.
-
-        component = self.component.resolve(context)
-
-        if self.fallback_render_method:
-            fallback_render_method = self.fallback_render_method.resolve(context)
-        else:
-            fallback_render_method = False
-
-        values = {
-            name: var.resolve(context) for name, var in self.extra_context.items()
-        }
-
-        if hasattr(component, "render_html"):
-            if self.isolated_context:
-                html = component.render_html(context.new(values))
-            else:
-                with context.push(**values):
-                    html = component.render_html(context)
-        elif fallback_render_method and hasattr(component, "render"):
-            html = component.render()
-        else:
-            raise ValueError(f"Cannot render {component!r} as a component")
-
-        if self.target_var:
-            context[self.target_var] = html
-            return ""
-        else:
-            if context.autoescape:
-                html = conditional_escape(html)
-            return html
-
-
-@register.tag(name="component")
-def component(parser, token):
-    bits = token.split_contents()[1:]
-    if not bits:
-        raise template.TemplateSyntaxError(
-            "'component' tag requires at least one argument, the component object"
-        )
-
-    component = parser.compile_filter(bits.pop(0))
-
-    # the only valid keyword argument immediately following the component
-    # is fallback_render_method
-    flags = token_kwargs(bits, parser)
-    fallback_render_method = flags.pop("fallback_render_method", None)
-    if flags:
-        raise template.TemplateSyntaxError(
-            "'component' tag only accepts 'fallback_render_method' as a keyword argument"
-        )
-
-    extra_context = {}
-    isolated_context = False
-    target_var = None
-
-    while bits:
-        bit = bits.pop(0)
-        if bit == "with":
-            extra_context = token_kwargs(bits, parser)
-        elif bit == "only":
-            isolated_context = True
-        elif bit == "as":
-            try:
-                target_var = bits.pop(0)
-            except IndexError:
-                raise template.TemplateSyntaxError(
-                    "'component' tag with 'as' must be followed by a variable name"
-                )
-        else:
-            raise template.TemplateSyntaxError(
-                "'component' tag received an unknown argument: %r" % bit
-            )
-
-    return ComponentNode(
-        component,
-        extra_context=extra_context,
-        isolated_context=isolated_context,
-        fallback_render_method=fallback_render_method,
-        target_var=target_var,
-    )
-
-
 class FragmentNode(template.Node):
     def __init__(self, nodelist, target_var):
         self.nodelist = nodelist
@@ -1247,3 +1145,8 @@ def human_readable_date(date, description=None, placement="top"):
         "description": description,
         "placement": placement,
     }
+
+
+# Shadow the laces `component` tag which was extracted from Wagtail. The shadowing
+# is useful to avoid having to update all the templates that use the `component` tag.
+register.tag("component", component)

+ 0 - 85
wagtail/admin/tests/test_templatetags.py

@@ -7,7 +7,6 @@ from django.template import Context, Template, TemplateSyntaxError
 from django.test import SimpleTestCase, TestCase
 from django.test.utils import override_settings
 from django.utils import timezone
-from django.utils.html import format_html
 from freezegun import freeze_time
 
 from wagtail.admin.staticfiles import versioned_static
@@ -20,7 +19,6 @@ from wagtail.admin.templatetags.wagtailadmin_tags import (
     timesince_simple,
 )
 from wagtail.admin.templatetags.wagtailadmin_tags import locales as locales_tag
-from wagtail.admin.ui.components import Component
 from wagtail.images.tests.utils import get_test_image_file
 from wagtail.models import Locale
 from wagtail.test.utils import WagtailTestUtils
@@ -222,89 +220,6 @@ class TestTimesinceTags(SimpleTestCase):
         self.assertIn('data-w-tooltip-placement-value="bottom"', html)
 
 
-class TestComponentTag(SimpleTestCase):
-    def test_passing_context_to_component(self):
-        class MyComponent(Component):
-            def render_html(self, parent_context):
-                return format_html(
-                    "<h1>{} was here</h1>", parent_context.get("first_name", "nobody")
-                )
-
-        template = Template(
-            "{% load wagtailadmin_tags %}{% with first_name='Kilroy' %}{% component my_component %}{% endwith %}"
-        )
-        html = template.render(Context({"my_component": MyComponent()}))
-        self.assertEqual(html, "<h1>Kilroy was here</h1>")
-
-        template = Template(
-            "{% load wagtailadmin_tags %}{% component my_component with first_name='Kilroy' %}"
-        )
-        html = template.render(Context({"my_component": MyComponent()}))
-        self.assertEqual(html, "<h1>Kilroy was here</h1>")
-
-        template = Template(
-            "{% load wagtailadmin_tags %}{% with first_name='Kilroy' %}{% component my_component with surname='Silk' only %}{% endwith %}"
-        )
-        html = template.render(Context({"my_component": MyComponent()}))
-        self.assertEqual(html, "<h1>nobody was here</h1>")
-
-    def test_fallback_render_method(self):
-        class MyComponent(Component):
-            def render_html(self, parent_context):
-                return format_html("<h1>I am a component</h1>")
-
-        class MyNonComponent:
-            def render(self):
-                return format_html("<h1>I am not a component</h1>")
-
-        template = Template("{% load wagtailadmin_tags %}{% component my_component %}")
-        html = template.render(Context({"my_component": MyComponent()}))
-        self.assertEqual(html, "<h1>I am a component</h1>")
-        with self.assertRaises(ValueError):
-            template.render(Context({"my_component": MyNonComponent()}))
-
-        template = Template(
-            "{% load wagtailadmin_tags %}{% component my_component fallback_render_method=True %}"
-        )
-        html = template.render(Context({"my_component": MyComponent()}))
-        self.assertEqual(html, "<h1>I am a component</h1>")
-        html = template.render(Context({"my_component": MyNonComponent()}))
-        self.assertEqual(html, "<h1>I am not a component</h1>")
-
-    def test_component_escapes_unsafe_strings(self):
-        class MyComponent(Component):
-            def render_html(self, parent_context):
-                return "Look, I'm running with scissors! 8< 8< 8<"
-
-        template = Template(
-            "{% load wagtailadmin_tags %}<h1>{% component my_component %}</h1>"
-        )
-        html = template.render(Context({"my_component": MyComponent()}))
-        self.assertEqual(
-            html, "<h1>Look, I&#x27;m running with scissors! 8&lt; 8&lt; 8&lt;</h1>"
-        )
-
-    def test_error_on_rendering_non_component(self):
-        template = Template(
-            "{% load wagtailadmin_tags %}<h1>{% component my_component %}</h1>"
-        )
-
-        with self.assertRaises(ValueError) as cm:
-            template.render(Context({"my_component": "hello"}))
-        self.assertEqual(str(cm.exception), "Cannot render 'hello' as a component")
-
-    def test_render_as_var(self):
-        class MyComponent(Component):
-            def render_html(self, parent_context):
-                return format_html("<h1>I am a component</h1>")
-
-        template = Template(
-            "{% load wagtailadmin_tags %}{% component my_component as my_html %}The result was: {{ my_html }}"
-        )
-        html = template.render(Context({"my_component": MyComponent()}))
-        self.assertEqual(html, "The result was: <h1>I am a component</h1>")
-
-
 @override_settings(
     WAGTAIL_CONTENT_LANGUAGES=[
         ("en", "English"),

+ 2 - 36
wagtail/admin/ui/components.py

@@ -1,36 +1,2 @@
-from typing import Any, MutableMapping
-
-from django.forms import Media, MediaDefiningClass
-from django.template import Context
-from django.template.loader import get_template
-
-
-class Component(metaclass=MediaDefiningClass):
-    def get_context_data(
-        self, parent_context: MutableMapping[str, Any]
-    ) -> MutableMapping[str, Any]:
-        return {}
-
-    def render_html(self, parent_context: MutableMapping[str, Any] = None) -> str:
-        if parent_context is None:
-            parent_context = Context()
-        context_data = self.get_context_data(parent_context)
-        if context_data is None:
-            raise TypeError("Expected a dict from get_context_data, got None")
-
-        template = get_template(self.template_name)
-        return template.render(context_data)
-
-
-class MediaContainer(list):
-    """
-    A list that provides a ``media`` property that combines the media definitions
-    of its members.
-    """
-
-    @property
-    def media(self):
-        media = Media()
-        for item in self:
-            media += item.media
-        return media
+# Import components from the Laces library which was extracted from Wagtail.
+from laces.components import Component, MediaContainer  # noqa: F401