Browse Source

Support passing extra context variables via the {% component %} tag

Matt Westcott 1 year ago
parent
commit
6009903c55

+ 1 - 0
CHANGELOG.txt

@@ -28,6 +28,7 @@ Changelog
  * Extract generic breadcrumbs functionality from page breadcrumbs (Sage Abdullah)
  * Add support for `placement` in the `human_readable_date` tooltip template tag (Rohit Sharma)
  * Add breadcrumbs to generic model views (Sage Abdullah)
+ * Support passing extra context variables via the `{% component %}` tag (Matt Westcott)
  * Fix: Ensure that StreamField's `FieldBlock`s correctly set the `required` and `aria-describedby` attributes (Storm Heg)
  * Fix: Avoid an error when the moderation panel (admin dashboard) contains both snippets and private pages (Matt Westcott)
  * Fix: When deleting collections, ensure the collection name is correctly shown in the success message (LB (Ben) Johnston)

+ 20 - 0
docs/extending/template_components.md

@@ -115,6 +115,26 @@ the `my_app/welcome.html` template could render the panels as follows:
 {% endfor %}
 ```
 
+You can pass additional context variables to the component using the keyword `with`:
+
+```html+django
+{% component panel with username=request.user.username %}
+```
+
+To render the component with only the variables provided (and no others from the calling template's context), use `only`:
+
+```html+django
+{% component panel with username=request.user.username only %}
+```
+
+To store the component's rendered output in a variable rather than outputting it immediately, use `as` followed by the variable name:
+
+```html+django
+{% component panel as panel_html %}
+
+{{ panel_html }}
+```
+
 Note that it is your template's responsibility to output any media declarations defined on the components. For a Wagtail admin view, this is best done by constructing a media object for the whole page within the view, passing this to the template, and outputting it via the base template's `extra_js` and `extra_css` blocks:
 
 ```python

+ 1 - 0
docs/releases/5.2.md

@@ -40,6 +40,7 @@ depth: 1
  * Extract generic breadcrumbs functionality from page breadcrumbs (Sage Abdullah)
  * Add support for `placement` in `human_readable_date` the tooltip template tag (Rohit Sharma)
  * Add breadcrumbs to generic model views (Sage Abdullah)
+ * Support passing extra context variables via the `{% component %}` tag (Matt Westcott)
 
 ### Bug fixes
 

+ 102 - 16
wagtail/admin/templatetags/wagtailadmin_tags.py

@@ -19,7 +19,7 @@ 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, json_script
+from django.utils.html import avoid_wrapping, conditional_escape, json_script
 from django.utils.http import urlencode
 from django.utils.safestring import mark_safe
 from django.utils.timesince import timesince
@@ -980,21 +980,107 @@ def resolve_url(url):
         return ""
 
 
-@register.simple_tag(takes_context=True)
-def component(context, obj, fallback_render_method=False):
-    # 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.
-
-    has_render_html_method = hasattr(obj, "render_html")
-    if fallback_render_method and not has_render_html_method and hasattr(obj, "render"):
-        return obj.render()
-    elif not has_render_html_method:
-        raise ValueError(f"Cannot render {obj!r} as a component")
-
-    return obj.render_html(context)
+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):

+ 47 - 1
wagtail/admin/tests/test_templatetags.py

@@ -228,7 +228,7 @@ class TestComponentTag(SimpleTestCase):
         class MyComponent(Component):
             def render_html(self, parent_context):
                 return format_html(
-                    "<h1>{} was here</h1>", parent_context.get("first_name")
+                    "<h1>{} was here</h1>", parent_context.get("first_name", "nobody")
                 )
 
         template = Template(
@@ -237,6 +237,41 @@ class TestComponentTag(SimpleTestCase):
         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):
@@ -259,6 +294,17 @@ class TestComponentTag(SimpleTestCase):
             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=[