Repository: wagtail/wagtail

Issue:
Add a more versatile Breadcrumbs component that can be used for go back to 'listing/index'
### Is your proposal related to a problem?

* There is currently no simple way to render the breadcrumbs that do not align with an existing page tree.
* Copying & pasting the tailwind classes to a different template is error prone and will likely mean styling regressions fixed in the main template may not get fixed up in the copied template.

### Describe the solution you'd like

* A new template tag that can render a simple breadcrumb of up to three items; the admin home page (always), a custom inner page (the parent) with a custom label, and an optional trailing label.
* Rename the existing one to `page_breadcrumbs` and make it an abstraction on top of the base breadcrumbs.
* Should be able to be expanded by default.
* Should be able to be used in the existing scenarios (however, usage in other context is out of scope for this enhancement)
  * Snippet edit view
  * ModelAdmin inspect view
  * ModelAdmin non-page edit view
  * ... potentially others
* Should be added to the pattern library (storybook)

### Describe alternatives you've considered

* Use a different visual approach instead of risking overloading the concept of breadcrumbs to only mean 'your page tree' in Wagtail.

### Additional context

* This would provide a solution for https://github.com/wagtail/wagtail/issues/8597
* This would provide a solution for https://github.com/wagtail/wagtail/issues/8037
* Split from https://github.com/wagtail/wagtail/issues/8645
* Relates to https://github.com/wagtail/wagtail/issues/8539
*  https://github.com/wagtail/wagtail/pull/8623 created that copy/pasted the existing breadcrumbs html leading to a bit of a maintenance risk

#### Existing code notes

**Breadcrumbs template tag**

https://github.com/wagtail/wagtail/blob/3fa153016b94a7ad39aa8d667a1268b49981bdaa/wagtail/admin/templatetags/wagtailadmin_tags.py#L59-L90

**Breadcrumbs core template**

https://github.com/wagtail/wagtail/blob/main/wagtail/admin/templates/wagtailadmin/shared/breadcrumbs.html

**Breadcrumbs copied/pasted into snippets**

https://github.com/wagtail/wagtail/blob/3fa153016b94a7ad39aa8d667a1268b49981bdaa/wagtail/snippets/templates/wagtailsnippets/snippets/headers/_base_header.html#L4

**Breacrumbs using classes from global context**

https://github.com/wagtail/wagtail/blob/main/wagtail/snippets/templates/wagtailsnippets/snippets/headers/list_header.html


#### How to implement this (proposed)

1. Change the existing `breadrumbs` template tag to `page_breadcrumbs`, update all occurrences
2. Create a new template `wagtail/admin/templates/wagtailadmin/shared/page_breadcrumbs.html` that includes the `wagtail/admin/templates/wagtailadmin/shared/breadcrumbs.html` (no other changes yet)
3. Use the new page specific template path in the `page_breadcrumbs` template tag.
4. Pull up everything that is within the `{% for page in pages %}` branch of logic into the new page breadcrumbs template and pass it in via a a `component` template tag to house the breadcrumb items, it can be passed in as `children` for simplicity.
5. Create a new template tag `breadcrumb`) which will abstract out the generation of a single inner tag item, use that in the page_breadcrumbs template tag.
6. Now create a new `breadcrumbs` template tag that allows children arg and some other generic args like trailing breadcrumb item (similar to `page_breadcrumbs`) and adopt that in the page_breadcrumbs template file instead of the include.
7. Now use this new approach to replace the approach in the snippets listing.
8. Finally, unit tests :D


#### Notes

The UI team agreed with the proposed approach of using breadcrumbs outside of just showing a page tree. 

- would be good to have a convenient way to include this as part of class based views
- we create a template tag `breadcrumbs_admin` or maybe `breadcrumbs_singleton`  / `breadcrumbs_simple`
- it accepts a single url_name (or maybe just `url`) and a label
- it always renders the root as the admin home and then the provided 
- trailing breadcrumb would still be made available for anything extra
- it would not use the word 'Root' but maybe 'Admin' for the base page
- it would not be collapsible/ just always be expanded
- usage; snippets redesign, ModelAdmin inspect and edit views, form submission listing, maybe users/groups edit
- it would add an extra modifier class (not sure though as this is using tailwind classes)





Discussion:
Updated title & content.
https://github.com/wagtail/wagtail/pull/8768 created to get feedback 
As per comment / discussion on draft PR - this issue may need to be updated to reflect the need for a full breadcrumbs template tag solution. 

With the ability to add any arbitrary breadcrumb items within it. 


https://github.com/wagtail/wagtail/pull/8768#issuecomment-1169599467

Another draft PR up - a potential generic approach for start/end template tags, if this could work we may have a nicer path forward for a general breadcrumbs tag.

https://github.com/wagtail/wagtail/pull/8775

@laymonage - could you please take a look when you get the chance.
This issue has been updated with more clarity and direction on how to set up a non-page breadcrumbs tag.

For Wagtail 4.0 snippets work - the existing breadcrumbs HTML with extensive Tailwind class usage was copied and pasted for https://github.com/wagtail/wagtail/pull/8623

This was done around the same time as the UX unification project so this duplicate breadcrumb got put in due to time constraints.

---

This is not really a good first issue but good for someone ready to pick up something a bit more complex with a clear goal that preserves existing behaviour.

Found a related ad-hoc breadcrumbs usage & added to issue.

https://github.com/wagtail/wagtail/blob/main/wagtail/snippets/templates/wagtailsnippets/snippets/headers/list_header.html
@lb- giving this one a try

This is likely needed for #10740. If that's the case, I'll see if I can pick this up for the next release. Will also see if #10160 can be continued to also close #10118.

Resolving patch:
diff --git a/wagtail/admin/templatetags/wagtailadmin_tags.py b/wagtail/admin/templatetags/wagtailadmin_tags.py
--- a/wagtail/admin/templatetags/wagtailadmin_tags.py
+++ b/wagtail/admin/templatetags/wagtailadmin_tags.py
@@ -65,8 +65,13 @@
 register.filter("naturaltime", naturaltime)
 
 
-@register.inclusion_tag("wagtailadmin/shared/breadcrumbs.html", takes_context=True)
-def breadcrumbs(
+@register.inclusion_tag("wagtailadmin/shared/breadcrumbs.html")
+def breadcrumbs(items, is_expanded=False, classname=None):
+    return {"items": items, "is_expanded": is_expanded, "classname": classname}
+
+
+@register.inclusion_tag("wagtailadmin/shared/page_breadcrumbs.html", takes_context=True)
+def page_breadcrumbs(
     context,
     page,
     url_name,
@@ -84,10 +89,10 @@ def breadcrumbs(
     # (i.e. add/edit/publish/lock) over; this will be the root of the breadcrumb
     cca = PagePermissionPolicy().explorable_root_instance(user)
     if not cca:
-        return {"pages": Page.objects.none()}
+        return {"items": Page.objects.none()}
 
     return {
-        "pages": page.get_ancestors(inclusive=include_self)
+        "items": page.get_ancestors(inclusive=include_self)
         .descendant_of(cca, inclusive=True)
         .specific(),
         "current_page": page,
diff --git a/wagtail/admin/views/generic/base.py b/wagtail/admin/views/generic/base.py
--- a/wagtail/admin/views/generic/base.py
+++ b/wagtail/admin/views/generic/base.py
@@ -22,6 +22,7 @@ class WagtailAdminTemplateMixin(TemplateResponseMixin, ContextMixin):
     page_title = ""
     page_subtitle = ""
     header_icon = ""
+    breadcrumbs_items = []
     template_name = "wagtailadmin/generic/base.html"
 
     def get_page_title(self):
@@ -33,11 +34,15 @@ def get_page_subtitle(self):
     def get_header_icon(self):
         return self.header_icon
 
+    def get_breadcrumbs_items(self):
+        return self.breadcrumbs_items
+
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         context["page_title"] = self.get_page_title()
         context["page_subtitle"] = self.get_page_subtitle()
         context["header_icon"] = self.get_header_icon()
+        context["breadcrumbs_items"] = self.get_breadcrumbs_items()
         return context
 
     def get_template_names(self):
diff --git a/wagtail/snippets/views/snippets.py b/wagtail/snippets/views/snippets.py
--- a/wagtail/snippets/views/snippets.py
+++ b/wagtail/snippets/views/snippets.py
@@ -5,7 +5,7 @@
 from django.core.exceptions import ImproperlyConfigured, PermissionDenied
 from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect
-from django.urls import path, re_path, reverse
+from django.urls import path, re_path, reverse, reverse_lazy
 from django.utils.functional import cached_property
 from django.utils.text import capfirst
 from django.utils.translation import gettext as _
@@ -24,6 +24,7 @@
     TitleColumn,
     UserColumn,
 )
+from wagtail.admin.utils import get_latest_str
 from wagtail.admin.views import generic
 from wagtail.admin.views.generic import history, lock, workflow
 from wagtail.admin.views.generic.permissions import PermissionCheckedMixin
@@ -179,6 +180,14 @@ def get_columns(self):
             *super().get_columns(),
         ]
 
+    def get_breadcrumbs_items(self):
+        return self.breadcrumbs_items + [
+            {
+                "url": self.get_index_url(),
+                "label": capfirst(self.model._meta.verbose_name_plural),
+            },
+        ]
+
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
 
@@ -252,6 +261,18 @@ def get_form_kwargs(self):
             "for_user": self.request.user,
         }
 
+    def get_breadcrumbs_items(self):
+        return self.breadcrumbs_items + [
+            {
+                "url": reverse(self.index_url_name),
+                "label": capfirst(self.model._meta.verbose_name_plural),
+            },
+            {
+                "label": _("New: %(model_name)s")
+                % {"model_name": capfirst(self.model._meta.verbose_name)},
+            },
+        ]
+
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
 
@@ -323,6 +344,15 @@ def _get_action_menu(self):
     def get_form_kwargs(self):
         return {**super().get_form_kwargs(), "for_user": self.request.user}
 
+    def get_breadcrumbs_items(self):
+        return self.breadcrumbs_items + [
+            {
+                "url": reverse(self.index_url_name),
+                "label": capfirst(self.model._meta.verbose_name_plural),
+            },
+            {"url": self.get_edit_url(), "label": get_latest_str(self.object)},
+        ]
+
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
 
@@ -389,6 +419,15 @@ def get_success_message(self):
             "object": self.object,
         }
 
+    def get_breadcrumbs_items(self):
+        return self.breadcrumbs_items + [
+            {
+                "url": reverse(self.index_url_name),
+                "label": capfirst(self.model._meta.verbose_name_plural),
+            },
+            {"url": self.get_delete_url(), "label": _("Delete")},
+        ]
+
 
 class UsageView(generic.UsageView):
     view_name = "usage"
@@ -396,6 +435,19 @@ class UsageView(generic.UsageView):
     permission_required = "change"
     edit_url_name = None
 
+    def get_breadcrumbs_items(self):
+        return self.breadcrumbs_items + [
+            {
+                "url": reverse(self.index_url_name),
+                "label": capfirst(self.model._meta.verbose_name_plural),
+            },
+            {
+                "url": reverse(self.edit_url_name, args=[quote(self.object.pk)]),
+                "label": get_latest_str(self.object),
+            },
+            {"label": _("Usage")},
+        ]
+
 
 class SnippetHistoryReportFilterSet(WagtailFilterSet):
     action = django_filters.ChoiceFilter(
@@ -479,6 +531,19 @@ def get_columns(self):
             DateColumn("timestamp", label=_("Date")),
         ]
 
+    def get_breadcrumbs_items(self):
+        return self.breadcrumbs_items + [
+            {
+                "url": reverse(self.index_url_name),
+                "label": capfirst(self.model._meta.verbose_name_plural),
+            },
+            {
+                "url": self.get_edit_url(self.object),
+                "label": get_latest_str(self.object),
+            },
+            {"label": _("History")},
+        ]
+
     def get_context_data(self, *args, object_list=None, **kwargs):
         context = super().get_context_data(*args, object_list=object_list, **kwargs)
         context["object"] = self.object
@@ -795,6 +860,7 @@ def get_common_view_kwargs(self, **kwargs):
                     "revisions_unschedule"
                 ),
                 "unpublish_url_name": self.get_url_name("unpublish"),
+                "breadcrumbs_items": self.breadcrumbs_items,
                 **kwargs,
             }
         )
@@ -1071,6 +1137,14 @@ def get_menu_order(self):
     def get_menu_item_is_registered(self):
         return self.menu_item_is_registered
 
+    @cached_property
+    def breadcrumbs_items(self):
+        # Use reverse_lazy instead of reverse
+        # because this will be passed to the view classes at startup
+        return [
+            {"url": reverse_lazy("wagtailsnippets:index"), "label": _("Snippets")},
+        ]
+
     def get_queryset(self, request):
         """
         Returns a QuerySet of all model instances to be shown on the index view.


Test patch:
diff --git a/wagtail/admin/tests/test_templatetags.py b/wagtail/admin/tests/test_templatetags.py
--- a/wagtail/admin/tests/test_templatetags.py
+++ b/wagtail/admin/tests/test_templatetags.py
@@ -4,7 +4,7 @@
 
 from django.conf import settings
 from django.template import Context, Template, TemplateSyntaxError
-from django.test import TestCase
+from django.test import SimpleTestCase, TestCase
 from django.test.utils import override_settings
 from django.utils import timezone
 from django.utils.html import format_html
@@ -65,7 +65,7 @@ def test_uploaded_avatar(self):
         self.assertIn("custom-avatar", url)
 
 
-class TestNotificationStaticTemplateTag(TestCase):
+class TestNotificationStaticTemplateTag(SimpleTestCase):
     @override_settings(STATIC_URL="/static/")
     def test_local_notification_static(self):
         url = notification_static("wagtailadmin/images/email-header.jpg")
@@ -97,7 +97,7 @@ def test_remote_notification_static(self):
         )
 
 
-class TestVersionedStatic(TestCase):
+class TestVersionedStatic(SimpleTestCase):
     def test_versioned_static(self):
         result = versioned_static("wagtailadmin/js/core.js")
         self.assertRegex(result, r"^/static/wagtailadmin/js/core.js\?v=(\w+)$")
@@ -119,7 +119,7 @@ def test_versioned_static_url(self):
 
 
 @freeze_time("2020-07-01 12:00:00")
-class TestTimesinceTags(TestCase):
+class TestTimesinceTags(SimpleTestCase):
     def test_timesince_simple(self):
         now = timezone.now()
         ts = timesince_simple(now)
@@ -208,7 +208,7 @@ def test_human_readable_date(self):
         self.assertIn("1\xa0hour ago", html)
 
 
-class TestComponentTag(TestCase):
+class TestComponentTag(SimpleTestCase):
     def test_passing_context_to_component(self):
         class MyComponent(Component):
             def render_html(self, parent_context):
@@ -292,7 +292,7 @@ def test_locale_label_from_id(self):
             self.assertIsNone(locale_label_from_id(self.locale_ids[-1] + 100), None)
 
 
-class ComponentTest(TestCase):
+class ComponentTest(SimpleTestCase):
     def test_render_block_component(self):
         template = """
             {% load wagtailadmin_tags %}
@@ -362,7 +362,7 @@ def test_render_as_variable(self):
         self.assertHTMLEqual(expected, Template(template).render(Context()))
 
 
-class FragmentTagTest(TestCase):
+class FragmentTagTest(SimpleTestCase):
     def test_basic(self):
         context = Context({})
 
@@ -414,7 +414,7 @@ def test_with_variables(self):
         self.assertHTMLEqual(expected, Template(template).render(context))
 
 
-class ClassnamesTagTest(TestCase):
+class ClassnamesTagTest(SimpleTestCase):
     def test_with_single_arg(self):
         template = """
             {% load wagtailadmin_tags %}
@@ -484,7 +484,7 @@ def test_with_args_with_extra_whitespace(self):
         self.assertEqual(expected.strip(), actual.strip())
 
 
-class IconTagTest(TestCase):
+class IconTagTest(SimpleTestCase):
     def test_basic(self):
         template = """
             {% load wagtailadmin_tags %}
@@ -580,7 +580,7 @@ def test_with_renamed_icon(self):
             self.assertHTMLEqual(expected, Template(template).render(Context()))
 
 
-class StatusTagTest(TestCase):
+class StatusTagTest(SimpleTestCase):
     def test_render_block_component_span_variations(self):
         template = """
             {% load wagtailadmin_tags i18n %}
@@ -652,3 +652,90 @@ def test_render_as_fragment(self):
         """
 
         self.assertHTMLEqual(expected, Template(template).render(Context()))
+
+
+class BreadcrumbsTagTest(WagtailTestUtils, SimpleTestCase):
+    template = """
+        {% load wagtailadmin_tags %}
+        {% breadcrumbs items %}
+    """
+
+    def assertItemsRendered(self, items, soup):
+        rendered_items = soup.select("ol > li")
+        arrows = soup.select("ol > li > svg")
+        self.assertEqual(len(rendered_items), len(items))
+        self.assertEqual(len(arrows), len(items) - 1)
+
+        for item, rendered_item in zip(items, rendered_items):
+            if item.get("url"):
+                element = rendered_item.select_one("a")
+                self.assertIsNotNone(element)
+                self.assertEqual(element["href"], item["url"])
+            else:
+                element = rendered_item.select_one("div")
+                self.assertIsNotNone(element)
+            self.assertEqual(element.text.strip(), item["label"])
+
+    def test_single_item(self):
+        items = [{"label": "Home", "url": "/admin/"}]
+        rendered = Template(self.template).render(Context({"items": items}))
+        soup = self.get_soup(rendered)
+        self.assertItemsRendered(items, soup)
+
+    def test_trailing_no_url(self):
+        items = [
+            {"label": "Snippets", "url": "/admin/snippets/"},
+            {"label": "People", "url": "/admin/snippets/people/"},
+            {"label": "New: Person"},
+        ]
+        rendered = Template(self.template).render(Context({"items": items}))
+        soup = self.get_soup(rendered)
+        self.assertItemsRendered(items, soup)
+
+    def test_not_is_expanded(self):
+        items = [
+            {"label": "Snippets", "url": "/admin/snippets/"},
+            {"label": "People", "url": "/admin/snippets/people/"},
+            {"label": "Muddy Waters", "url": "/admin/snippets/people/1/edit/"},
+        ]
+        rendered = Template(self.template).render(Context({"items": items}))
+        soup = self.get_soup(rendered)
+        self.assertItemsRendered(items, soup)
+
+        controller = soup.select_one('[data-controller="w-breadcrumbs"]')
+        toggle_button = soup.select_one('button[data-w-breadcrumbs-target="toggle"]')
+        self.assertIsNotNone(controller)
+        self.assertIsNotNone(toggle_button)
+
+    def test_is_expanded(self):
+        template = """
+            {% load wagtailadmin_tags %}
+            {% breadcrumbs items is_expanded=True %}
+        """
+        items = [
+            {"label": "Snippets", "url": "/admin/snippets/"},
+            {"label": "People", "url": "/admin/snippets/people/"},
+            {"label": "Muddy Waters", "url": "/admin/snippets/people/1/edit/"},
+        ]
+        rendered = Template(template).render(Context({"items": items}))
+        soup = self.get_soup(rendered)
+        self.assertItemsRendered(items, soup)
+
+        controller = soup.select_one('[data-controller="w-breadcrumbs"]')
+        toggle_button = soup.select_one('button[data-w-breadcrumbs-target="toggle"]')
+        self.assertIsNone(controller)
+        self.assertIsNone(toggle_button)
+
+    def test_classname(self):
+        template = """
+            {% load wagtailadmin_tags %}
+            {% breadcrumbs items classname="my-class" %}
+        """
+        items = [{"label": "Home", "url": "/admin/"}]
+        rendered = Template(template).render(Context({"items": items}))
+        soup = self.get_soup(rendered)
+        self.assertItemsRendered(items, soup)
+
+        div = soup.select_one("div.w-breadcrumbs")
+        self.assertIsNotNone(div)
+        self.assertIn("my-class", div["class"])
diff --git a/wagtail/snippets/tests/test_snippets.py b/wagtail/snippets/tests/test_snippets.py
--- a/wagtail/snippets/tests/test_snippets.py
+++ b/wagtail/snippets/tests/test_snippets.py
@@ -3751,7 +3751,7 @@ def test_delete_get_with_protected_reference(self):
             + "?describe_on_delete=1",
         )
         self.assertNotContains(response, "Yes, delete")
-        self.assertNotContains(response, delete_url)
+        self.assertNotContains(response, f'<form action="{delete_url}" method="POST">')
 
     def test_delete_post_with_limited_permissions(self):
         self.user.is_superuser = False