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 %}

+

{{ header_title }}

{{ user|user_display_name }}
diff --git a/wagtail/admin/templates/wagtailadmin/shared/dialog/dialog.html b/wagtail/admin/templates/wagtailadmin/shared/dialog/dialog.html index a9fca189b3..6104dabfd2 100644 --- a/wagtail/admin/templates/wagtailadmin/shared/dialog/dialog.html +++ b/wagtail/admin/templates/wagtailadmin/shared/dialog/dialog.html @@ -32,8 +32,8 @@

{{ subtitle }}

{% 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 = """ +
+ + Proceed with caution +
+ """ + + self.assertHTMLEqual(expected, Template(template).render(Context())) + + def test_render_nested(self): + template = """ + {% load wagtailadmin_tags %} + {% help_block status="warning" %} + {% help_block status="info" %}Proceed with caution{% endhelp_block %} + {% endhelp_block %} + """ + + expected = """ +
+ +
+ + Proceed with caution +
+
+ """ + + self.assertHTMLEqual(expected, Template(template).render(Context())) + + def test_kwargs_with_filters(self): + template = """ + {% load wagtailadmin_tags %} + {% help_block status="warning"|upper %}Proceed with caution{% endhelp_block %} + """ + + expected = """ +
+ + Proceed with caution +
+ """ + + self.assertHTMLEqual(expected, Template(template).render(Context())) + + def test_render_as_variable(self): + template = """ + {% load wagtailadmin_tags %} + {% help_block status="info" as help %}Proceed with caution{% endhelp_block %} + + """ + + expected = """ + + """ + + self.assertHTMLEqual(expected, Template(template).render(Context())) + + +class FragmentTagTest(TestCase): + def test_basic(self): + context = Context({}) + + template = """ + {% load wagtailadmin_tags %} + {% fragment as my_fragment %} +

Hello, World

+ {% endfragment %} + Text coming after: + {{ my_fragment }} + """ + + expected = """ + Text coming after: +

Hello, World

+ """ + + self.assertHTMLEqual(expected, Template(template).render(context)) + + @override_settings(DEBUG=True) + def test_syntax_error(self): + template = """ + {% load wagtailadmin_tags %} + {% fragment %} +

Hello, World

+ {% endfragment %} + """ + + with self.assertRaises(TemplateSyntaxError): + Template(template).render(Context()) + + def test_with_variables(self): + context = Context({"name": "jonathan wells"}) + + template = """ + {% load wagtailadmin_tags %} + {% fragment as my_fragment %} +

Hello, {{ name|title }}

+ {% endfragment %} + Text coming after: + {{ my_fragment }} + """ + + expected = """ + Text coming after: +

Hello, Jonathan Wells

+ """ + + 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.

-
- {% icon name='help' %} + {% help_block status="info" %}

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. -
+ {% endhelp_block %}