Browse Source

Introduce new template fragment composition tags

Thibaud Colas 2 years ago
parent
commit
524cab82e3

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

+ 1 - 0
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)

+ 1 - 1
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:

+ 1 - 0
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
 

+ 5 - 1
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 %}
+
     <header class="header merged header--home">
         <div class="avatar"><img src="{% avatar_url user %}" alt="" /></div>
 
         <div class="sm:w-ml-4">
-            <h1 class="header__title">{% block branding_welcome %}{% blocktrans trimmed %}Welcome to the {{ site_name }} Wagtail CMS{% endblocktrans %}{% endblock %}</h1>
+            <h1 class="header__title">{{ header_title }}</h1>
             <div class="user-name">{{ user|user_display_name }}</div>
         </div>
     </header>

+ 5 - 5
wagtail/admin/templates/wagtailadmin/shared/dialog/dialog.html

@@ -32,8 +32,8 @@
                     <p class="w-dialog__subtitle w-help-text">{{ subtitle }}</p>
                 {% 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 }}
+            </div>
+        </div>
+    </div>
+</template>

+ 0 - 8
wagtail/admin/templates/wagtailadmin/shared/dialog/end_dialog.html

@@ -1,8 +0,0 @@
-</div>
-</div>
-</div>
-</template>
-
-{% 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 %}

+ 10 - 0
wagtail/admin/templates/wagtailadmin/shared/help_block.html

@@ -0,0 +1,10 @@
+{% load wagtailadmin_tags %}
+
+<div class="help-block help-{{ status }}">
+    {% if status == 'info' %}
+        {% icon name='help' %}
+    {% else %}
+        {% icon name='warning' %}
+    {% endif %}
+    {{ children }}
+</div>

+ 135 - 47
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,
-    }
+    return FragmentNode(nodelist, target_var)
+
+
+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)
 
-    # 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
+class HelpBlockNode(BlockInclusionNode):
+    template = "wagtailadmin/shared/help_block.html"
 
 
-# Closing tag for dialog tag {% enddialog %}
-@register.inclusion_tag("wagtailadmin/shared/dialog/end_dialog.html")
-def enddialog():
-    return
+register.tag("help_block", HelpBlockNode.handle)
 
 
 # Button used to open dialogs

+ 123 - 1
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 = """
+            <div class="help-block help-info">
+                <svg aria-hidden="true" class="icon icon icon-help"><use href="#icon-help"></svg>
+                Proceed with caution
+            </div>
+        """
+
+        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 = """
+            <div class="help-block help-warning">
+                <svg aria-hidden="true" class="icon icon icon-warning"><use href="#icon-warning"></svg>
+                <div class="help-block help-info">
+                    <svg aria-hidden="true" class="icon icon icon-help"><use href="#icon-help"></svg>
+                    Proceed with caution
+                </div>
+            </div>
+        """
+
+        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 = """
+            <div class="help-block help-WARNING">
+                <svg aria-hidden="true" class="icon icon icon-warning"><use href="#icon-warning"></svg>
+                Proceed with caution
+            </div>
+        """
+
+        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 %}
+            <template>{{ help }}</template>
+        """
+
+        expected = """
+            <template>
+                <div class="help-block help-info">
+                    <svg aria-hidden="true" class="icon icon icon-help"><use href="#icon-help"></svg>
+                    Proceed with caution
+                </div>
+            </template>
+        """
+
+        self.assertHTMLEqual(expected, Template(template).render(Context()))
+
+
+class FragmentTagTest(TestCase):
+    def test_basic(self):
+        context = Context({})
+
+        template = """
+            {% load wagtailadmin_tags %}
+            {% fragment as my_fragment %}
+            <p>Hello, World</p>
+            {% endfragment %}
+            Text coming after:
+            {{ my_fragment }}
+        """
+
+        expected = """
+            Text coming after:
+            <p>Hello, World</p>
+        """
+
+        self.assertHTMLEqual(expected, Template(template).render(context))
+
+    @override_settings(DEBUG=True)
+    def test_syntax_error(self):
+        template = """
+            {% load wagtailadmin_tags %}
+            {% fragment %}
+            <p>Hello, World</p>
+            {% 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 %}
+                <p>Hello, {{ name|title }}</p>
+            {% endfragment %}
+            Text coming after:
+            {{ my_fragment }}
+        """
+
+        expected = """
+            Text coming after:
+            <p>Hello, Jonathan Wells</p>
+        """
+
+        self.assertHTMLEqual(expected, Template(template).render(context))

+ 6 - 9
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.
             </p>
 
-            <div class="help-block help-info">
-                {% icon name='help' %}
+            {% help_block status="info" %}
                 <p>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</p>
                 <p>It could be multiple lines</p>
-            </div>
+            {% endhelp_block %}
 
-            <p class="help-block help-warning">
-                {% icon name='warning' %}
+            {% help_block status="warning" %}
                 A warning message might be output in cases where a user's action could have serious consequences
-            </p>
+            {% endhelp_block %}
 
-            <div class="help-block help-critical">
-                {% 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.
-            </div>
+            {% endhelp_block %}
 
         </section>