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

Fixes #11105
pull/11295/head
Tibor Leupold 2023-11-29 14:45:27 -08:00 zatwierdzone przez Matt Westcott
rodzic cce05fb1a3
commit 10c1e12285
6 zmienionych plików z 12 dodań i 225 usunięć

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

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

Wyświetl plik

@ -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