diff --git a/.circleci/config.yml b/.circleci/config.yml
index 6d6267a159..ff5e6d5cdc 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -23,7 +23,7 @@ jobs:
- run: pipenv run isort --check-only --diff .
- run: pipenv run black --target-version py37 --check --diff .
- run: git ls-files '*.html' | xargs pipenv run djhtml --check
- - run: pipenv run curlylint --exclude '(dialog.html|end_dialog.html)' --parse-only wagtail
+ - run: pipenv run curlylint --parse-only wagtail
- run: pipenv run doc8 docs
- run: DATABASE_NAME=wagtail.db pipenv run python -u runtests.py
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index a1b9935596..bec51e4458 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -53,6 +53,7 @@ Changelog
* Added `WAGTAILADMIN_USER_PASSWORD_RESET_FORM` setting for overriding the admin password reset form (Michael Karamuth)
* Prefetch workflow states in edit page view to to avoid queries in other parts of the view/templates that need it (Tidiane Dia)
* Remove the edit link from edit bird in previews to avoid confusion (Sævar Öfjörð Magnússon)
+ * Introduce new template fragment and block level enclosure tags for easier template composition (Thibaud Colas)
* Fix: Typo in `ResumeWorkflowActionFormatter` message (Stefan Hammer)
* Fix: Throw a meaningful error when saving an image to an unrecognised image format (Christian Franke)
* Fix: Remove extra padding for headers with breadcrumbs on mobile viewport (Steven Steinwand)
diff --git a/Makefile b/Makefile
index 352ca89083..7e0d60a6cd 100644
--- a/Makefile
+++ b/Makefile
@@ -21,7 +21,7 @@ lint-server:
black --target-version py37 --check --diff .
flake8
isort --check-only --diff .
- curlylint --exclude '(dialog.html|end_dialog.html)' --parse-only wagtail
+ curlylint --parse-only wagtail
git ls-files '*.html' | xargs djhtml --check
lint-client:
diff --git a/docs/releases/4.0.md b/docs/releases/4.0.md
index 0555ddc8ca..de3a646c86 100644
--- a/docs/releases/4.0.md
+++ b/docs/releases/4.0.md
@@ -60,6 +60,7 @@ When using a queryset to render a list of images, you can now use the `prefetch_
* Added `WAGTAILADMIN_USER_PASSWORD_RESET_FORM` setting for overriding the admin password reset form (Michael Karamuth)
* Prefetch workflow states in edit page view to to avoid queries in other parts of the view/templates that need it (Tidiane Dia)
* Remove the edit link from edit bird in previews to avoid confusion (Sævar Öfjörð Magnússon)
+ * Introduce new template fragment and block level enclosure tags for easier template composition (Thibaud Colas)
### Bug fixes
diff --git a/wagtail/admin/templates/wagtailadmin/home.html b/wagtail/admin/templates/wagtailadmin/home.html
index 090c212858..f4f1e99e85 100644
--- a/wagtail/admin/templates/wagtailadmin/home.html
+++ b/wagtail/admin/templates/wagtailadmin/home.html
@@ -8,11 +8,15 @@
{% endblock %}
{% block content %}
+ {% fragment as header_title %}
+ {% block branding_welcome %}{% blocktrans trimmed %}Welcome to the {{ site_name }} Wagtail CMS{% endblocktrans %}{% endblock %}
+ {% endfragment %}
+
-
{% block branding_welcome %}{% blocktrans trimmed %}Welcome to the {{ site_name }} Wagtail CMS{% endblocktrans %}{% endblock %}
{% endif %}
-
- {% comment %}
- This markup is intentionally left without closing div tags so that the contents can be populated with child elements between dialog and enddialog
- For the end tags please see end-dialog.html
- {% endcomment %}
+ {{ children }}
+
+
+
+
diff --git a/wagtail/admin/templates/wagtailadmin/shared/dialog/end_dialog.html b/wagtail/admin/templates/wagtailadmin/shared/dialog/end_dialog.html
deleted file mode 100644
index 4fdd390e79..0000000000
--- a/wagtail/admin/templates/wagtailadmin/shared/dialog/end_dialog.html
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-{% comment %}
- This markup is used to close the end tags for dialog.html so that content can be nested between both tags like {% dialog %}{% enddialog %}
-{% endcomment %}
diff --git a/wagtail/admin/templates/wagtailadmin/shared/help_block.html b/wagtail/admin/templates/wagtailadmin/shared/help_block.html
new file mode 100644
index 0000000000..48ca31bed4
--- /dev/null
+++ b/wagtail/admin/templates/wagtailadmin/shared/help_block.html
@@ -0,0 +1,10 @@
+{% load wagtailadmin_tags %}
+
+
+ {% if status == 'info' %}
+ {% icon name='help' %}
+ {% else %}
+ {% icon name='warning' %}
+ {% endif %}
+ {{ children }}
+
diff --git a/wagtail/admin/templatetags/wagtailadmin_tags.py b/wagtail/admin/templatetags/wagtailadmin_tags.py
index 559eff5be9..92b57e63c6 100644
--- a/wagtail/admin/templatetags/wagtailadmin_tags.py
+++ b/wagtail/admin/templatetags/wagtailadmin_tags.py
@@ -8,6 +8,8 @@ from django.contrib.admin.utils import quote
from django.contrib.humanize.templatetags.humanize import intcomma, naturaltime
from django.contrib.messages.constants import DEFAULT_TAGS as MESSAGE_TAGS
from django.shortcuts import resolve_url as resolve_url_func
+from django.template import Context
+from django.template.base import token_kwargs
from django.template.defaultfilters import stringfilter
from django.templatetags.static import static
from django.urls import reverse
@@ -859,61 +861,147 @@ def component(context, obj, fallback_render_method=False):
return obj.render_html(context)
-@register.inclusion_tag("wagtailadmin/shared/dialog/dialog.html")
-def dialog(
- id,
- title,
- icon_name=None,
- subtitle=None,
- message_status=None,
- message_heading=None,
- message_description=None,
-):
+class FragmentNode(template.Node):
+ def __init__(self, nodelist, target_var):
+ self.nodelist = nodelist
+ self.target_var = target_var
+
+ def render(self, context):
+ fragment = self.nodelist.render(context) if self.nodelist else ""
+ context[self.target_var] = fragment
+ return ""
+
+
+@register.tag(name="fragment")
+def fragment(parser, token):
"""
- Dialog tag - to be used with its corresponding {% enddialog %} tag with dialog content markup nested between
+ Store a template fragment as a variable.
+
+ Usage:
+ {% fragment as header_title %}
+ {% blocktrans trimmed %}Welcome to the {{ site_name }} Wagtail CMS{% endblocktrans %}
+ {% fragment %}
+
+ Copy-paste of slippers’ fragment template tag.
+ See https://github.com/mixxorz/slippers/blob/254c720e6bb02eb46ae07d104863fce41d4d3164/slippers/templatetags/slippers.py#L173.
"""
- if not title:
- raise ValueError("You must supply a title")
- if not id:
- raise ValueError("You must supply an id")
+ error_message = "The syntax for fragment is {% fragment as variable_name %}"
- # Used for determining which icon the message will use
- message_status_type = {
- "info": {
- "message_icon_name": "info-circle",
- },
- "warning": {
- "message_icon_name": "warning",
- },
- "critical": {
- "message_icon_name": "warning",
- },
- "success": {
- "message_icon_name": "circle-check",
- },
- }
+ try:
+ tag_name, _, target_var = token.split_contents()
+ nodelist = parser.parse(("endfragment",))
+ parser.delete_first_token()
+ except ValueError:
+ if settings.DEBUG:
+ raise template.TemplateSyntaxError(error_message)
+ return ""
- context = {
- "id": id,
- "title": title,
- "icon_name": icon_name,
- "subtitle": subtitle,
- "message_heading": message_heading,
- "message_description": message_description,
- "message_status": message_status,
- }
-
- # If there is a message status then add the context for that message type
- if message_status:
- context.update(**message_status_type[message_status])
-
- return context
+ return FragmentNode(nodelist, target_var)
-# Closing tag for dialog tag {% enddialog %}
-@register.inclusion_tag("wagtailadmin/shared/dialog/end_dialog.html")
-def enddialog():
- return
+class BlockInclusionNode(template.Node):
+ """
+ Create template-driven tags like Django’s inclusion_tag / InclusionNode, but for block-level tags.
+
+ Usage:
+ {% my_tag status="test" label="Alert" %}
+ Proceed with caution.
+ {% endmy_tag %}
+
+ Within `my_tag`’s template, the template fragment will be accessible as the {{ children }} context variable.
+
+ The output can also be stored as a variable in the parent context:
+
+ {% my_tag status="test" label="Alert" as my_variable %}
+ Proceed with caution.
+ {% endmy_tag %}
+
+ Inspired by slippers’ Component Node.
+ See https://github.com/mixxorz/slippers/blob/254c720e6bb02eb46ae07d104863fce41d4d3164/slippers/templatetags/slippers.py#L47.
+ """
+
+ def __init__(self, nodelist, template, extra_context, target_var=None):
+ self.nodelist = nodelist
+ self.template = template
+ self.extra_context = extra_context
+ self.target_var = target_var
+
+ def get_context_data(self, parent_context):
+ return parent_context
+
+ def render(self, context):
+ children = self.nodelist.render(context) if self.nodelist else ""
+
+ values = {
+ # Resolve the tag’s parameters within the current context.
+ key: value.resolve(context)
+ for key, value in self.extra_context.items()
+ }
+
+ t = context.template.engine.get_template(self.template)
+ # Add the `children` variable in the rendered template’s context.
+ context_data = self.get_context_data({**values, "children": children})
+ output = t.render(Context(context_data, autoescape=context.autoescape))
+
+ if self.target_var:
+ context[self.target_var] = output
+ return ""
+
+ return output
+
+ @classmethod
+ def handle(cls, parser, token):
+ tag_name, *remaining_bits = token.split_contents()
+
+ nodelist = parser.parse((f"end{tag_name}",))
+ parser.delete_first_token()
+
+ extra_context = token_kwargs(remaining_bits, parser)
+
+ # Allow component fragment to be assigned to a variable
+ target_var = None
+ if len(remaining_bits) >= 2 and remaining_bits[-2] == "as":
+ target_var = remaining_bits[-1]
+
+ return cls(nodelist, cls.template, extra_context, target_var)
+
+
+class DialogNode(BlockInclusionNode):
+ template = "wagtailadmin/shared/dialog/dialog.html"
+
+ def get_context_data(self, parent_context):
+ context = super().get_context_data(parent_context)
+
+ if "title" not in context:
+ raise TypeError("You must supply a title")
+ if "id" not in context:
+ raise TypeError("You must supply an id")
+
+ # Used for determining which icon the message will use
+ message_icon_name = {
+ "info": "info-circle",
+ "warning": "warning",
+ "critical": "warning",
+ "success": "circle-check",
+ }
+
+ message_status = context.get("message_status")
+
+ # If there is a message status then determine which icon to use.
+ if message_status:
+ context["message_icon_name"] = message_icon_name[message_status]
+
+ return context
+
+
+register.tag("dialog", DialogNode.handle)
+
+
+class HelpBlockNode(BlockInclusionNode):
+ template = "wagtailadmin/shared/help_block.html"
+
+
+register.tag("help_block", HelpBlockNode.handle)
# Button used to open dialogs
diff --git a/wagtail/admin/tests/test_templatetags.py b/wagtail/admin/tests/test_templatetags.py
index 40ce154ce2..3461013039 100644
--- a/wagtail/admin/tests/test_templatetags.py
+++ b/wagtail/admin/tests/test_templatetags.py
@@ -3,7 +3,7 @@ from datetime import timedelta
from unittest import mock
from django.conf import settings
-from django.template import Context, Template
+from django.template import Context, Template, TemplateSyntaxError
from django.test import TestCase
from django.test.utils import override_settings
from django.utils import timezone
@@ -247,3 +247,125 @@ class TestInternationalisationTags(TestCase):
# check with an invalid id
with self.assertNumQueries(0):
self.assertIsNone(locale_label_from_id(self.locale_ids[-1] + 100), None)
+
+
+class ComponentTest(TestCase):
+ def test_render_block_component(self):
+ template = """
+ {% load wagtailadmin_tags %}
+ {% help_block status="info" %}Proceed with caution{% endhelp_block %}
+ """
+
+ expected = """
+
+ """
+
+ self.assertHTMLEqual(expected, Template(template).render(context))
diff --git a/wagtail/contrib/styleguide/templates/wagtailstyleguide/base.html b/wagtail/contrib/styleguide/templates/wagtailstyleguide/base.html
index 0e14247920..1d8fed7b78 100644
--- a/wagtail/contrib/styleguide/templates/wagtailstyleguide/base.html
+++ b/wagtail/contrib/styleguide/templates/wagtailstyleguide/base.html
@@ -256,21 +256,18 @@
Help text is not to be confused with the messages that appear in a banner drop down from the top of the screen. Help text are permanent instructions, visible on every page view, that explain or warn about something.
-
This is help text that might be just for information, explaining what happens next, or drawing the user's attention to something they're about to do
It could be multiple lines
-
+ {% endhelp_block %}
-
- {% icon name='warning' %}
+ {% help_block status="warning" %}
A warning message might be output in cases where a user's action could have serious consequences
-
+ {% endhelp_block %}
-
- {% icon name='warning' %}
+ {% help_block status="critical" %}
A critical message would probably be rare, in cases where a particularly brittle or dangerously destructive action could be performed and needs to be warned about.
-