Browse Source

Snippets bulk actions (#8574)

Shohan 2 năm trước cách đây
mục cha
commit
7286c530e9

+ 7 - 3
client/src/entrypoints/admin/bulk-actions.js

@@ -272,7 +272,11 @@ function rebindBulkActionsEventListeners() {
 
 document.addEventListener('DOMContentLoaded', addBulkActionListeners);
 if (window.headerSearch) {
-  document
-    .querySelector(window.headerSearch.termInput)
-    .addEventListener('search-success', rebindBulkActionsEventListeners);
+  const termInput = document.querySelector(window.headerSearch.termInput);
+  if (termInput) {
+    termInput.addEventListener(
+      'search-success',
+      rebindBulkActionsEventListeners,
+    );
+  }
 }

+ 0 - 5
client/webpack.config.js

@@ -196,11 +196,6 @@ module.exports = function exports(env, argv) {
             to: 'wagtail/search/static/',
             globOptions: { ignore: ['**/{app,scss}/**', '*.{css,txt}'] },
           },
-          {
-            from: 'wagtail/snippets/static_src/',
-            to: 'wagtail/snippets/static/',
-            globOptions: { ignore: ['**/{app,scss}/**', '*.{css,txt}'] },
-          },
           {
             from: 'wagtail/users/static_src/',
             to: 'wagtail/users/static/',

+ 275 - 0
docs/extending/custom_bulk_actions.rst

@@ -0,0 +1,275 @@
+.. _custom_bulk_actions:
+
+Adding custom bulk actions
+==========================================
+
+This document describes how to add custom bulk actions to different listings.
+
+
+Registering a custom bulk action
+--------------------------------
+
+.. code-block:: python
+
+    from wagtail.admin.views.bulk_action import BulkAction
+    from wagtail import hooks
+
+
+    @hooks.register('register_bulk_action')
+    class CustomDeleteBulkAction(BulkAction):
+        display_name = _("Delete")
+        aria_label = _("Delete selected objects")
+        action_type = "delete"
+        template_name = "/path/to/confirm_bulk_delete.html"
+        models = [...]
+
+        @classmethod
+        def execute_action(cls, objects, **kwargs):
+            for obj in objects:
+                do_something(obj)
+            return num_parent_objects, num_child_objects  # return the count of updated objects
+
+The attributes are as follows:
+
+- ``display_name`` - The label that will be displayed on the button in the user interface
+- ``aria_label`` - The ``aria-label`` attribute that will be applied to the button in the user interface
+- ``action_type`` - A unique identifier for the action. Will be required in the url for bulk actions
+- ``template_name`` - The path to the confirmation template
+- ``models`` - A list of models on which the bulk action can act
+- ``action_priority`` (optional) - A number that is used to determine the placement of the button in the list of buttons
+- ``classes`` (optional) - A set of CSS classnames that will be used on the button in the user interface
+
+An example for a confirmation template is as follows:
+
+.. code-block:: django
+
+  <!-- /path/to/confirm_bulk_delete.html -->
+
+  {% extends 'wagtailadmin/bulk_actions/confirmation/base.html' %}
+  {% load i18n wagtailadmin_tags %}
+
+  {% block titletag %}{% blocktrans trimmed count counter=items|length %}Delete 1 item{% plural %}Delete {{ counter }} items{% endblocktrans %}{% endblock %}
+
+  {% block header %}
+      {% trans "Delete" as del_str %}
+      {% include "wagtailadmin/shared/header.html" with title=del_str icon="doc-empty-inverse" %}
+  {% endblock header %}
+
+  {% block items_with_access %}
+          {% if items %}
+          <p>{% trans "Are you sure you want to delete these items?" %}</p>
+          <ul>
+              {% for item in items %}
+              <li>
+                  <a href="" target="_blank" rel="noreferrer">{{ item.item.title }}</a>
+              </li>
+              {% endfor %}
+          </ul>
+          {% endif %}
+  {% endblock items_with_access %}
+
+  {% block items_with_no_access %}
+
+  {% blocktrans trimmed asvar no_access_msg count counter=items_with_no_access|length %}You don't have permission to delete this item{% plural %}You don't have permission to delete these items{% endblocktrans %}
+  {% include './list_items_with_no_access.html' with items=items_with_no_access no_access_msg=no_access_msg %}
+
+  {% endblock items_with_no_access %}
+
+  {% block form_section %}
+  {% if items %}
+      {% trans 'Yes, delete' as action_button_text %}
+      {% trans "No, don't delete" as no_action_button_text %}
+      {% include 'wagtailadmin/bulk_actions/confirmation/form.html' with action_button_class="serious" %}
+  {% else %}
+      {% include 'wagtailadmin/bulk_actions/confirmation/go_back.html' %}
+  {% endif %}
+  {% endblock form_section %}
+
+
+.. code-block:: django
+
+  <!-- ./list_items_with_no_access.html -->
+  {% extends 'wagtailadmin/bulk_actions/confirmation/list_items_with_no_access.html' %}
+  {% load i18n %}
+
+  {% block per_item %}
+      {% if item.can_edit %}
+      <a href="{% url 'wagtailadmin_pages:edit' item.item.id %}" target="_blank" rel="noreferrer">{{ item.item.title }}</a>
+      {% else %}
+      {{ item.item.title }}
+      {% endif %}
+  {% endblock per_item %}
+
+
+The ``execute_action`` classmethod is the only method that must be overridden for the bulk action to work properly. It
+takes a list of objects as the only required argument, and a bunch of keyword arguments that can be supplied by overriding
+the ``get_execution_context`` method. For example.
+
+.. code-block:: python
+
+    @classmethod
+    def execute_action(cls, objects, **kwargs):
+        # the kwargs here is the output of the get_execution_context method
+        user = kwargs.get('user', None)
+        num_parent_objects, num_child_objects = 0, 0
+        # you could run the action per object or run them in bulk using django's bulk update and delete methods
+        for obj in objects:
+            num_child_objects += obj.get_children().count()
+            num_parent_objects += 1
+            obj.delete(user=user)
+            num_parent_objects += 1
+        return num_parent_objects, num_child_objects
+
+
+The ``get_execution_context`` method can be overridden to provide context to the ``execute_action``
+
+.. code-block:: python
+
+    def get_execution_context(self):
+        return {
+            'user': self.request.user
+        }
+
+
+The ``get_context_data`` method can be overridden to pass additional context to the confirmation template.
+
+.. code-block:: python
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['new_key'] = some_value
+        return context
+
+
+The ``check_perm`` method can be overridden to check if an object has some permission or not. objects for which the ``check_perm``
+returns ``False`` will be available in the context under the key ``'items_with_no_access'``.
+
+.. code-block:: python
+
+    def check_perm(self, obj):
+        return obj.has_perm('some_perm')  # returns True or False
+
+
+The success message shown on the admin can be customised by overriding the ``get_success_message`` method.
+
+.. code-block:: python
+
+    def get_success_message(self, num_parent_objects, num_child_objects):
+        return _("{} objects, including {} child objects have been updated".format(num_parent_objects, num_child_objects))
+
+
+
+Adding bulk actions to the page explorer
+----------------------------------------
+
+When creating a custom bulk action class for pages, subclass from ``wagtail.admin.views.pages.bulk_actions.page_bulk_action.PageBulkAction``
+instead of ``wagtail.admin.views.bulk_action.BulkAction``
+
+Basic example
+~~~~~~~~~~~~~
+
+.. code-block:: python
+
+    from wagtail.admin.views.pages.bulk_actions.page_bulk_action import PageBulkAction
+    from wagtail import hooks
+
+
+    @hooks.register('register_bulk_action')
+    class CustomPageBulkAction(PageBulkAction):
+        ...
+
+
+
+Adding bulk actions to the Images listing
+-----------------------------------------
+
+When creating a custom bulk action class for images, subclass from ``wagtail.images.views.bulk_actions.image_bulk_action.ImageBulkAction``
+instead of ``wagtail.admin.views.bulk_action.BulkAction``
+
+Basic example
+~~~~~~~~~~~~~
+
+.. code-block:: python
+
+    from wagtail.images.views.bulk_actions.image_bulk_action import ImageBulkAction
+    from wagtail import hooks
+
+
+    @hooks.register('register_bulk_action')
+    class CustomImageBulkAction(ImageBulkAction):
+        ...
+
+
+
+Adding bulk actions to the documents listing
+--------------------------------------------
+
+When creating a custom bulk action class for documents, subclass from ``wagtail.documents.views.bulk_actions.document_bulk_action.DocumentBulkAction``
+instead of ``wagtail.admin.views.bulk_action.BulkAction``
+
+Basic example
+~~~~~~~~~~~~~
+
+.. code-block:: python
+
+    from wagtail.documents.views.bulk_actions.document_bulk_action import DocumentBulkAction
+    from wagtail import hooks
+
+
+    @hooks.register('register_bulk_action')
+    class CustomDocumentBulkAction(DocumentBulkAction):
+        ...
+
+
+
+Adding bulk actions to the user listing
+---------------------------------------
+
+When creating a custom bulk action class for users, subclass from ``wagtail.users.views.bulk_actions.user_bulk_action.UserBulkAction``
+instead of ``wagtail.admin.views.bulk_action.BulkAction``
+
+Basic example
+~~~~~~~~~~~~~
+
+.. code-block:: python
+
+    from wagtail.users.views.bulk_actions.user_bulk_action import UserBulkAction
+    from wagtail import hooks
+
+
+    @hooks.register('register_bulk_action')
+    class CustomUserBulkAction(UserBulkAction):
+        ...
+
+
+Adding bulk actions to the snippets listing
+-------------------------------------------
+
+When creating a custom bulk action class for snippets, subclass from ``wagtail.snippets.bulk_actions.snippet_bulk_action.SnippetBulkAction``
+instead of ``wagtail.admin.views.bulk_action.BulkAction``
+
+Basic example
+~~~~~~~~~~~~~
+
+.. code-block:: python
+
+    from wagtail.snippets.bulk_actions.snippet_bulk_action import SnippetBulkAction
+    from wagtail import hooks
+
+
+    @hooks.register('register_bulk_action')
+    class CustomSnippetBulkAction(SnippetBulkAction):
+        ...
+
+If you want to apply an action only to certain snippets, override the ``models`` list in the action class
+
+.. code-block:: python
+
+    from wagtail.snippets.bulk_actions.snippet_bulk_action import SnippetBulkAction
+    from wagtail import hooks
+
+
+    @hooks.register('register_bulk_action')
+    class CustomSnippetBulkAction(SnippetBulkAction):
+        models = [SnippetA, SnippetB]
+        ...

+ 6 - 0
wagtail/admin/localization.py

@@ -79,6 +79,12 @@ def get_js_translation_strings():
                 "ALL": _("All {0} users on this screen selected"),
                 "ALL_IN_LISTING": _("All users in listing selected"),
             },
+            "SNIPPET": {
+                "SINGULAR": _("1 snippet selected"),
+                "PLURAL": _("{0} snippets selected"),
+                "ALL": _("All {0} snippets on this screen selected"),
+                "ALL_IN_LISTING": _("All snippets in listing selected"),
+            },
             "ITEM": {
                 "SINGULAR": _("1 item selected"),
                 "PLURAL": _("{0} items selected"),

+ 1 - 0
wagtail/admin/views/bulk_action/base_bulk_action.py

@@ -129,6 +129,7 @@ class BulkAction(ABC, FormView):
         request = self.request
         self.cleaned_form = form
         objects, objects_without_access = self.get_actionable_objects()
+        self.actionable_objects = objects
         resp = self.prepare_action(objects, objects_without_access)
         if hasattr(resp, "status_code"):
             return resp

+ 3 - 0
wagtail/snippets/bulk_actions/__init__.py

@@ -0,0 +1,3 @@
+from .delete import DeleteBulkAction
+
+__all__ = ["DeleteBulkAction"]

+ 46 - 0
wagtail/snippets/bulk_actions/delete.py

@@ -0,0 +1,46 @@
+from django.utils.text import capfirst
+from django.utils.translation import gettext_lazy as _
+from django.utils.translation import ngettext
+
+from wagtail.snippets.bulk_actions.snippet_bulk_action import SnippetBulkAction
+from wagtail.snippets.permissions import get_permission_name
+
+
+class DeleteBulkAction(SnippetBulkAction):
+    display_name = _("Delete")
+    action_type = "delete"
+    aria_label = _("Delete selected snippets")
+    template_name = "wagtailsnippets/bulk_actions/confirm_bulk_delete.html"
+    action_priority = 30
+    classes = {"serious"}
+
+    def check_perm(self, snippet):
+        if getattr(self, "can_delete_items", None) is None:
+            # since snippets permissions are not enforced per object, makes sense to just check once per model request
+            self.can_delete_items = self.request.user.has_perm(
+                get_permission_name("delete", self.model)
+            )
+        return self.can_delete_items
+
+    @classmethod
+    def execute_action(cls, objects, user=None, **kwargs):
+        kwargs["self"].model.objects.filter(
+            pk__in=[snippet.pk for snippet in objects]
+        ).delete()
+        return len(objects), 0
+
+    def get_success_message(self, num_parent_objects, num_child_objects):
+        if num_parent_objects == 1:
+            return _("%(snippet_type)s '%(instance)s' deleted.") % {
+                "snippet_type": capfirst(self.model._meta.verbose_name),
+                "instance": self.actionable_objects[0],
+            }
+        else:
+            return ngettext(
+                "%(count)d %(snippet_type)s deleted.",
+                "%(count)d %(snippet_type)s deleted.",
+                num_parent_objects,
+            ) % {
+                "snippet_type": capfirst(self.model._meta.verbose_name_plural),
+                "count": num_parent_objects,
+            }

+ 30 - 0
wagtail/snippets/bulk_actions/snippet_bulk_action.py

@@ -0,0 +1,30 @@
+from django.contrib.admin.utils import quote
+from django.urls import reverse
+
+from wagtail.admin.views.bulk_action import BulkAction
+from wagtail.snippets.models import get_snippet_models
+
+
+def get_edit_url(app_label, model_name, snippet):
+    return reverse(
+        f"wagtailsnippets_{app_label}_{model_name}:edit", args=[quote(snippet.pk)]
+    )
+
+
+class SnippetBulkAction(BulkAction):
+    models = get_snippet_models()
+
+    def object_context(self, snippet):
+        return {
+            "item": snippet,
+            "edit_url": get_edit_url(
+                self.model._meta.app_label, self.model._meta.model_name, snippet
+            ),
+        }
+
+    def get_context_data(self, **kwargs):
+        kwargs.update({"model_opts": self.model._meta})
+        return super().get_context_data(**kwargs)
+
+    def get_execution_context(self):
+        return {**super().get_execution_context(), "self": self}

+ 0 - 111
wagtail/snippets/static_src/wagtailsnippets/js/snippet-multiple-select.js

@@ -1,111 +0,0 @@
-var updateRow = function (id, newValue) {
-  var $row = $('table.listing tr#snippet-row-' + id);
-  var $checklist = $row.find('input[type=checkbox].toggle-select-row');
-  $checklist.prop('checked', newValue);
-  if (newValue) {
-    $row.addClass('selected');
-  } else {
-    $row.removeClass('selected');
-  }
-};
-
-var updateDeleteButton = function (anySelected, newState) {
-  var $deleteButton = $('a.button.delete-button');
-  var ids = [];
-  $.each(newState, function (id, newValue) {
-    if (newValue) {
-      ids.push(id);
-    }
-  });
-  if (anySelected) {
-    // hide button and add url
-    $deleteButton.removeClass('u-hidden');
-    var url = $deleteButton.data('url');
-    url += $.param({ id: ids }, true);
-    $deleteButton.attr('href', url);
-  } else {
-    // show button and remove url
-    $deleteButton.addClass('u-hidden');
-    $deleteButton.attr('href', null);
-  }
-};
-
-var updateSelectAllCheckbox = function (value) {
-  var $selectAllCheckbox = $(
-    'table.listing input[type=checkbox].toggle-select-all',
-  );
-  $selectAllCheckbox.prop('checked', value);
-};
-
-var buildSelectedState = function () {
-  // prepare the selected state -- {3: true, 4: false}
-  var state = {};
-  var $rows = $(
-    'table.listing tbody tr input[type=checkbox].toggle-select-row',
-  );
-  $.each($rows, function (index, row) {
-    var $row = $(row);
-    var selected = $row.prop('checked');
-    var id = $row.attr('value');
-    state[id] = selected;
-  });
-  return state;
-};
-
-var updateSelectedState = function (state, newValue, idToUpdate) {
-  if (idToUpdate === null) {
-    // update all rows
-    $.each(state, function (id, currentValue) {
-      state[id] = newValue;
-    });
-  } else {
-    // update single row
-    state[idToUpdate] = newValue;
-  }
-  return state;
-};
-
-var updateView = function (newState) {
-  var allSelected = true;
-  var anySelected = false;
-  var countOfUnselected = 0;
-  var countOfSelected = 0;
-
-  // update each row with the new value (selected or not)
-  $.each(newState, function (id, newValue) {
-    updateRow(id, newValue);
-    if (newValue === false) {
-      countOfUnselected += 1;
-    } else {
-      countOfSelected += 1;
-    }
-  });
-
-  // update the main checkbox for select all (if all are true)
-  if (countOfUnselected >= 1) {
-    allSelected = false;
-  }
-  updateSelectAllCheckbox(allSelected);
-
-  // update the delete button
-  if (countOfSelected >= 1) {
-    anySelected = true;
-  }
-  updateDeleteButton(anySelected, newState);
-};
-
-var onListingCheckboxClick = function () {
-  $('table.listing input[type="checkbox"]').on('click', function (event) {
-    var $target = $(event.target);
-    var setToValue = $target.prop('checked');
-    var currentState = buildSelectedState();
-    var id = null;
-    if ($target.hasClass('toggle-select-row')) {
-      id = $target.attr('value');
-    }
-    var newState = updateSelectedState(currentState, setToValue, id);
-    updateView(newState);
-  });
-};
-
-$(document).ready(onListingCheckboxClick);

+ 60 - 0
wagtail/snippets/templates/wagtailsnippets/bulk_actions/confirm_bulk_delete.html

@@ -0,0 +1,60 @@
+{% extends 'wagtailadmin/bulk_actions/confirmation/base.html' %}
+{% load i18n wagtailusers_tags wagtailadmin_tags %}
+
+{% block titletag %}
+    {% if items|length == 1 %}
+        {% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Delete {{ snippet_type_name }}{% endblocktrans %} - {{ items|first }}
+    {% else %}
+        {{ items|length }} {{ model_opts.verbose_name_plural|capfirst }}
+    {% endif %}
+{% endblock %}
+
+{% block header %}
+    {% trans "Delete " as delete_str %}
+    {% if items|length == 1 %}
+        {% include "wagtailadmin/shared/header.html" with title=delete_str subtitle=items.0.item icon="snippet" only %}
+    {% else %}
+        {% include "wagtailadmin/shared/header.html" with title=delete_str subtitle=model_opts.verbose_name_plural|capfirst icon="snippet" only %}
+    {% endif %}
+{% endblock header %}
+
+{% block items_with_access %}
+    {% if items %}
+        {% if items|length == 1 %}
+            {% usage_count_enabled as uc_enabled %}
+            {% if uc_enabled %}
+                <div class="usagecount">
+                    <a href="{{ items.0.item.usage_url }}">{% blocktrans trimmed count usage_count=items.0.item.get_usage.count %}Used {{ usage_count }} time{% plural %}Used {{ usage_count }} times{% endblocktrans %}</a>
+                </div>
+            {% endif %}
+            <p>{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name %}Are you sure you want to delete this {{ snippet_type_name }}?{% endblocktrans %}</p>
+        {% else %}
+            <p>{% blocktrans trimmed with snippet_type_name=model_opts.verbose_name_plural count=items|length %}Are you sure you want to delete {{ count }} {{ snippet_type_name }}?{% endblocktrans %}</p>
+            <ul>
+                {% for snippet in items %}
+                    <li>
+                        <a href="{{ snippet.edit_url }}" target="_blank" rel="noreferrer">{{ snippet.item }}</a>
+                    </li>
+                {% endfor %}
+            </ul>
+        {% endif %}
+    {% endif %}
+{% endblock items_with_access %}
+
+
+{% block items_with_no_access %}
+
+    {% blocktrans with snippet_type_name=model_opts.verbose_name snippet_plural_name=model_opts.verbose_name_plural|capfirst trimmed asvar no_access_msg count counter=items_with_no_access|length  %}You don't have permission to delete this {{ snippet_type_name }}{% plural %}You don't have permission to delete these {{ snippet_plural_name }} {% endblocktrans %}
+    {% include 'wagtailsnippets/bulk_actions/list_items_with_no_access.html' with items=items_with_no_access no_access_msg=no_access_msg %}
+
+{% endblock items_with_no_access %}
+
+{% block form_section %}
+    {% if items %}
+        {% trans 'Yes, delete' as action_button_text %}
+        {% trans "No, don't delete" as no_action_button_text %}
+        {% include 'wagtailadmin/bulk_actions/confirmation/form.html' with action_button_class="serious" %}
+    {% else %}
+        {% include 'wagtailadmin/bulk_actions/confirmation/go_back.html' %}
+    {% endif %}
+{% endblock form_section %}

+ 6 - 0
wagtail/snippets/templates/wagtailsnippets/bulk_actions/list_items_with_no_access.html

@@ -0,0 +1,6 @@
+{% extends 'wagtailadmin/bulk_actions/confirmation/list_items_with_no_access.html' %}
+{% load i18n wagtailadmin_tags %}
+
+{% block per_item %}
+    {{ item }}
+{% endblock per_item %}

+ 3 - 13
wagtail/snippets/templates/wagtailsnippets/snippets/list.html

@@ -1,27 +1,17 @@
-{% load i18n wagtailadmin_tags wagtailsnippets_admin_tags %}
+{% load i18n l10n wagtailadmin_tags wagtailsnippets_admin_tags %}
 <table class="listing">
     {% if can_delete_snippets %}<col width="5%" />{% endif %}
     <col />
     <thead>
         <tr class="table-headers">
-            {% if can_delete_snippets %}
-                <th>
-                    <input type="checkbox" class="toggle-select-all" id="toggle-select-all-snippets" />
-                    <label for="toggle-select-all-snippets" class="visuallyhidden">{% blocktrans trimmed with snippet_type_name_plural=model_opts.verbose_name_plural %}Select all {{ snippet_type_name_plural }}{% endblocktrans %}</label>
-                </th>
-            {% endif %}
+            {% include 'wagtailadmin/bulk_actions/select_all_checkbox_cell.html' %}
             <th>{% trans "Title" %}</th>
         </tr>
     </thead>
     <tbody>
         {% for snippet in items %}
             <tr id="snippet-row-{{ snippet.pk }}">
-                {% if can_delete_snippets %}
-                    <td class="select">
-                        <input type="checkbox" name="select_snippet" id="select-snippet-{{ snippet.pk }}" value="{{ snippet.pk }}" class="toggle-select-row"/>
-                        <label for="select-snippet-{{ snippet.pk }}" class="visuallyhidden">{% blocktrans trimmed %}Select {{ snippet }}{% endblocktrans %}</label>
-                    </td>
-                {% endif %}
+                {% include "wagtailadmin/bulk_actions/listing_checkbox_cell.html" with obj_type="snippet" obj=snippet aria_labelledby_prefix="snippet_" aria_labelledby=snippet.pk|unlocalize aria_labelledby_suffix="_title" %}
                 <td class="title">
                     <div class="title-wrapper"><a href="{% url view.edit_url_name snippet.pk|admin_urlquote %}">{{ snippet }}</a></div>
                     <ul class="actions">

+ 4 - 11
wagtail/snippets/templates/wagtailsnippets/snippets/type_index.html

@@ -10,10 +10,9 @@
             termInput: "#id_q",
             targetOutput: "#snippet-results"
         }
+        window.wagtailConfig.BULK_ACTION_ITEM_TYPE = 'SNIPPET';
     </script>
-    {% if can_delete_snippets %}
-        <script src="{% versioned_static 'wagtailsnippets/js/snippet-multiple-select.js' %}"></script>
-    {% endif %}
+    <script defer src="{% versioned_static 'wagtailadmin/js/bulk-actions.js' %}"></script>
 {% endblock %}
 
 {% block content %}
@@ -46,14 +45,6 @@
             <div class="right col">
                 <form>  {# HACK Removes list-style-type #}
                     <ul class="fields row rowflush">
-                        {% if can_delete_snippets %}
-                            <li class="col">
-                                <a class="button bicolor button--icon serious delete-button u-hidden" data-url="{% url view.delete_multiple_url_name %}?">
-                                    {% icon name="bin" wrapped=1 %}
-                                    {% blocktrans trimmed with snippet_type_name=model_opts.verbose_name_plural %}Delete {{ snippet_type_name }}{% endblocktrans %}
-                                </a>
-                            </li>
-                        {% endif %}
                         {% if locale %}
                             <li class="col">
                                 <div class="field">
@@ -79,5 +70,7 @@
         <div id="snippet-results" class="snippets">
             {% include "wagtailsnippets/snippets/results.html" %}
         </div>
+        {% trans "Select all snippets in listing" as select_all_text %}
+        {% include 'wagtailadmin/bulk_actions/footer.html' with select_all_obj_text=select_all_text app_label=model_opts.app_label model_name=model_opts.model_name objects=items %}
     </div>
 {% endblock %}

+ 0 - 0
wagtail/snippets/tests/__init__.py


+ 0 - 0
wagtail/snippets/tests/test_bulk_actions/__init__.py


+ 81 - 0
wagtail/snippets/tests/test_bulk_actions/test_bulk_delete.py

@@ -0,0 +1,81 @@
+from django.contrib.auth.models import Permission
+from django.test import TestCase
+from django.urls import reverse
+from django.utils.text import capfirst
+
+from wagtail.test.snippets.models import StandardSnippet
+from wagtail.test.utils import WagtailTestUtils
+
+
+class TestSnippetDeleteView(TestCase, WagtailTestUtils):
+    def setUp(self):
+        self.snippet_model = StandardSnippet
+
+        # create a set of test snippets
+        self.test_snippets = [
+            self.snippet_model.objects.create(
+                text=f"Title-{i}",
+            )
+            for i in range(1, 6)
+        ]
+
+        self.user = self.login()
+        self.url = (
+            reverse(
+                "wagtail_bulk_action",
+                args=(
+                    self.snippet_model._meta.app_label,
+                    self.snippet_model._meta.model_name,
+                    "delete",
+                ),
+            )
+            + "?"
+        )
+        for snippet in self.test_snippets:
+            self.url += f"id={snippet.pk}&"
+
+    def test_simple(self):
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 200)
+        self.assertTemplateUsed(
+            response, "wagtailsnippets/bulk_actions/confirm_bulk_delete.html"
+        )
+
+    def test_bulk_delete(self):
+        response = self.client.post(self.url)
+
+        # Should redirect back to index
+        self.assertEqual(response.status_code, 302)
+
+        # Check that the users were deleted
+        for snippet in self.test_snippets:
+            self.assertFalse(self.snippet_model.objects.filter(pk=snippet.pk).exists())
+
+    def test_delete_with_limited_permissions(self):
+        self.user.is_superuser = False
+        self.user.user_permissions.add(
+            Permission.objects.get(
+                content_type__app_label="wagtailadmin", codename="access_admin"
+            )
+        )
+        self.user.save()
+
+        response = self.client.get(self.url)
+        self.assertEqual(response.status_code, 200)
+
+        html = response.content.decode()
+        self.assertInHTML(
+            f"<p>You don't have permission to delete these {capfirst(self.snippet_model._meta.verbose_name_plural)}</p>",
+            html,
+        )
+
+        for snippet in self.test_snippets:
+            self.assertInHTML(f"<li>{snippet.text}</li>", html)
+
+        response = self.client.post(self.url)
+        # User should be redirected back to the index
+        self.assertEqual(response.status_code, 302)
+
+        # Documents should not be deleted
+        for snippet in self.test_snippets:
+            self.assertTrue(self.snippet_model.objects.filter(pk=snippet.pk).exists())

+ 0 - 0
wagtail/snippets/tests.py → wagtail/snippets/tests/test_snippets.py


+ 4 - 0
wagtail/snippets/wagtail_hooks.py

@@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
 
 from wagtail import hooks
 from wagtail.admin.menu import MenuItem
+from wagtail.snippets.bulk_actions.delete import DeleteBulkAction
 from wagtail.snippets.models import get_snippet_models
 from wagtail.snippets.permissions import (
     get_permission_name,
@@ -101,3 +102,6 @@ def register_snippet_listing_buttons(snippet, user, next_url=None):
             priority=20,
             classes=["no"],
         )
+
+
+hooks.register("register_bulk_action", DeleteBulkAction)

+ 3 - 0
wagtail/test/snippets/models.py

@@ -66,6 +66,9 @@ class SearchableSnippet(index.Indexed, models.Model):
 class StandardSnippet(models.Model):
     text = models.CharField(max_length=255)
 
+    def __str__(self):
+        return self.text
+
 
 @register_snippet
 class FancySnippet(models.Model):