diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 8857005898..c928836ba7 100644 --- a/CHANGELOG.txt +++ b/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) diff --git a/docs/extending/template_components.md b/docs/extending/template_components.md index 765b70e545..4bb9d1f72e 100644 --- a/docs/extending/template_components.md +++ b/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 diff --git a/docs/releases/5.2.md b/docs/releases/5.2.md index 3fd743fb12..e91d787868 100644 --- a/docs/releases/5.2.md +++ b/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 diff --git a/wagtail/admin/templatetags/wagtailadmin_tags.py b/wagtail/admin/templatetags/wagtailadmin_tags.py index de9cb9d375..19f228ee18 100644 --- a/wagtail/admin/templatetags/wagtailadmin_tags.py +++ b/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. +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 - 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") + 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. - return obj.render_html(context) + 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): diff --git a/wagtail/admin/tests/test_templatetags.py b/wagtail/admin/tests/test_templatetags.py index 8b7868ae85..8deb93ee2d 100644 --- a/wagtail/admin/tests/test_templatetags.py +++ b/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( - "

{} was here

", parent_context.get("first_name") + "

{} was here

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

Kilroy was here

") + template = Template( + "{% load wagtailadmin_tags %}{% component my_component with first_name='Kilroy' %}" + ) + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

Kilroy was here

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

nobody was here

") + + def test_fallback_render_method(self): + class MyComponent(Component): + def render_html(self, parent_context): + return format_html("

I am a component

") + + class MyNonComponent: + def render(self): + return format_html("

I am not a component

") + + template = Template("{% load wagtailadmin_tags %}{% component my_component %}") + html = template.render(Context({"my_component": MyComponent()})) + self.assertEqual(html, "

I am a component

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

I am a component

") + html = template.render(Context({"my_component": MyNonComponent()})) + self.assertEqual(html, "

I am not a component

") + 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("

I am a component

") + + 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:

I am a component

") + @override_settings( WAGTAIL_CONTENT_LANGUAGES=[