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 isort --check-only --diff .
       - run: pipenv run black --target-version py37 --check --diff .
       - run: pipenv run black --target-version py37 --check --diff .
       - run: git ls-files '*.html' | xargs pipenv run djhtml --check
       - 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: pipenv run doc8 docs
       - run: DATABASE_NAME=wagtail.db pipenv run python -u runtests.py
       - 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)
  * 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)
  * 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)
  * 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: Typo in `ResumeWorkflowActionFormatter` message (Stefan Hammer)
  * Fix: Throw a meaningful error when saving an image to an unrecognised image format (Christian Franke)
  * 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)
  * 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 .
 	black --target-version py37 --check --diff .
 	flake8
 	flake8
 	isort --check-only --diff .
 	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
 	git ls-files '*.html' | xargs djhtml --check
 
 
 lint-client:
 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)
  * 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)
  * 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)
  * 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
 ### Bug fixes
 
 

+ 5 - 1
wagtail/admin/templates/wagtailadmin/home.html

@@ -8,11 +8,15 @@
 {% endblock %}
 {% endblock %}
 
 
 {% block content %}
 {% 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">
     <header class="header merged header--home">
         <div class="avatar"><img src="{% avatar_url user %}" alt="" /></div>
         <div class="avatar"><img src="{% avatar_url user %}" alt="" /></div>
 
 
         <div class="sm:w-ml-4">
         <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 class="user-name">{{ user|user_display_name }}</div>
         </div>
         </div>
     </header>
     </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>
                     <p class="w-dialog__subtitle w-help-text">{{ subtitle }}</p>
                 {% endif %}
                 {% 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.humanize.templatetags.humanize import intcomma, naturaltime
 from django.contrib.messages.constants import DEFAULT_TAGS as MESSAGE_TAGS
 from django.contrib.messages.constants import DEFAULT_TAGS as MESSAGE_TAGS
 from django.shortcuts import resolve_url as resolve_url_func
 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.template.defaultfilters import stringfilter
 from django.templatetags.static import static
 from django.templatetags.static import static
 from django.urls import reverse
 from django.urls import reverse
@@ -859,61 +861,147 @@ def component(context, obj, fallback_render_method=False):
     return obj.render_html(context)
     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
 # 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 unittest import mock
 
 
 from django.conf import settings
 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 import TestCase
 from django.test.utils import override_settings
 from django.test.utils import override_settings
 from django.utils import timezone
 from django.utils import timezone
@@ -247,3 +247,125 @@ class TestInternationalisationTags(TestCase):
         # check with an invalid id
         # check with an invalid id
         with self.assertNumQueries(0):
         with self.assertNumQueries(0):
             self.assertIsNone(locale_label_from_id(self.locale_ids[-1] + 100), None)
             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.
                 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>
             </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>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>
                 <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
                 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.
                 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>
         </section>