فهرست منبع

[feat] Add support for filters and bulk actions (del, pub, and unpub)

Added following
- Add support for filters
- Add bulk delete, publish and unpublish actions (views and templates)
- Add search textbox to get filtered results
- Add view to get count of pages corresponding to a set of given filters
Shohan 4 سال پیش
والد
کامیت
f875484f73

+ 1 - 0
client/scss/components/_listing.scss

@@ -120,6 +120,7 @@ ul.listing {
             input {
                 background-color: #fff;
                 border: 0;
+                color: #000;
             }
 
             button {

+ 117 - 26
client/src/entrypoints/admin/bulk-actions.js

@@ -1,56 +1,147 @@
-const BULK_ACTION_CHECKBOX_CLASS = 'bulk-action-checkbox';
-const BULK_ACTION_CHECKBOX_FILTER_CLASS = 'bulk-actions-filter-checkbox';
-const BULK_ACTION_CHOICES_CLASS = 'bulk-actions-choices';
-const TABLE_HEADERS_CLASS = 'table-headers';
+const BULK_ACTION_PAGE_CHECKBOX_INPUT = 'bulk-action-checkbox';
+const BULK_ACTION_SELECT_ALL_CHECKBOX_TH = 'bulk-actions-filter-checkbox';
+const BULK_ACTION_FILTERS_CLASS = `${BULK_ACTION_SELECT_ALL_CHECKBOX_TH} .c-dropdown__item a`;
+const BULK_ACTION_CHOICES_DIV = 'bulk-actions-choices';
+const BULK_ACTION_NUM_PAGES_SPAN = 'num-pages';
+const BULK_ACTION_NUM_PAGES_IN_LISTING_SPAN = 'num-pages-in-listing';
+const TABLE_HEADERS_TR = 'table-headers';
 
 const checkedState = {
   checkedPages: new Set(),
+  numPages: 0,
 };
 
+/* Event listener for the `Select All` checkbox */
 function SelectBulkActionsFilter(e) {
   const changeEvent = new Event('change');
-  for (const el of document.querySelectorAll(`.${BULK_ACTION_CHECKBOX_CLASS}`)) {
+  for (const el of document.querySelectorAll(`.${BULK_ACTION_PAGE_CHECKBOX_INPUT}`)) {
     if (el.checked === e.target.checked) continue;
     el.checked = e.target.checked;
     el.dispatchEvent(changeEvent);
   }
 }
 
+
+/* Event listener for individual page checkbox */
 function SelectBulkActionsCheckboxes(e) {
   const prevLength = checkedState.checkedPages.size;
   if (e.target.checked) checkedState.checkedPages.add(+e.target.dataset.pageId);
   else {
-    // unchecks `select all` checkbox as soon as one page is unchecked
-    document.querySelector(`.${BULK_ACTION_CHECKBOX_FILTER_CLASS} input`).checked = false;
+    /* unchecks `Select all` checkbox as soon as one page is unchecked */
+    document.querySelector(`.${BULK_ACTION_SELECT_ALL_CHECKBOX_TH} input`).checked = false;
     checkedState.checkedPages.delete(+e.target.dataset.pageId);
   }
 
   if (checkedState.checkedPages.size === 0) {
-    // all checboxes are unchecked
-    document.querySelectorAll(`.${TABLE_HEADERS_CLASS} > th`).forEach(el => el.classList.remove('u-hidden'));
-    document.querySelector(`.${BULK_ACTION_CHOICES_CLASS}`).classList.add('u-hidden');
-    document.querySelectorAll(`.${BULK_ACTION_CHECKBOX_CLASS}`).forEach(el => el.classList.remove('show'));
-    document.querySelector(`.${BULK_ACTION_CHECKBOX_FILTER_CLASS}`).setAttribute('colspan', '1');
-  } else if (checkedState.checkedPages.size === document.querySelectorAll(`.${BULK_ACTION_CHECKBOX_CLASS}`).length) {
-    // all checkboxes are checked
-    document.querySelector(`.${BULK_ACTION_CHECKBOX_FILTER_CLASS} input`).checked = true;
+    /* when all checboxes are unchecked */
+    document.querySelectorAll(`.${TABLE_HEADERS_TR} > th`).forEach(el => el.classList.remove('u-hidden'));
+    document.querySelector(`.${BULK_ACTION_CHOICES_DIV}`).classList.add('u-hidden');
+    document.querySelectorAll(`.${BULK_ACTION_PAGE_CHECKBOX_INPUT}`).forEach(el => el.classList.remove('show'));
+    document.querySelector(`.${BULK_ACTION_SELECT_ALL_CHECKBOX_TH}`).setAttribute('colspan', '1');
   } else if (checkedState.checkedPages.size === 1 && prevLength === 0) {
-    // 1 checkbox is checked for the first time
-    document.querySelectorAll(`.${BULK_ACTION_CHECKBOX_CLASS}`).forEach(el => {
+    /* when 1 checkbox is checked for the first time */
+    document.querySelectorAll(`.${BULK_ACTION_PAGE_CHECKBOX_INPUT}`).forEach(el => {
       el.classList.remove('show');
       el.classList.add('show');
     });
-    document.querySelectorAll(`.${TABLE_HEADERS_CLASS} > th`).forEach(el => el.classList.add('u-hidden'));
-    document.querySelector(`.${BULK_ACTION_CHECKBOX_FILTER_CLASS}`).classList.remove('u-hidden');
-    document.querySelector(`.${BULK_ACTION_CHOICES_CLASS}`).classList.remove('u-hidden');
-    document.querySelector(`.${BULK_ACTION_CHECKBOX_FILTER_CLASS}`).setAttribute('colspan', '6');
+    document.querySelectorAll(`.${TABLE_HEADERS_TR} > th`).forEach(el => el.classList.add('u-hidden'));
+    document.querySelector(`.${BULK_ACTION_SELECT_ALL_CHECKBOX_TH}`).classList.remove('u-hidden');
+    document.querySelector(`.${BULK_ACTION_CHOICES_DIV}`).classList.remove('u-hidden');
+    document.querySelector(`.${BULK_ACTION_SELECT_ALL_CHECKBOX_TH}`).setAttribute('colspan', '6');
+  }
+
+  if (checkedState.checkedPages.size === checkedState.numPages) {
+    /* when all checkboxes in the page are checked */
+    document.querySelector(`.${BULK_ACTION_SELECT_ALL_CHECKBOX_TH} input`).checked = true;
+  }
+
+  if (checkedState.checkedPages.size > 0) {
+    /* Update text on number of pages */
+    document.querySelector(`.${BULK_ACTION_NUM_PAGES_SPAN}`).textContent =
+    `${checkedState.checkedPages.size === checkedState.numPages ? 'All ' : ''} ${checkedState.checkedPages.size}`;
   }
 }
 
-function AddBulkActionCheckboxEventListeners() {
-  document.querySelectorAll(`.${BULK_ACTION_CHECKBOX_CLASS}`)
-    .forEach(el => el.addEventListener('change', SelectBulkActionsCheckboxes));
-  document.querySelector(`.${BULK_ACTION_CHECKBOX_FILTER_CLASS}`).addEventListener('change', SelectBulkActionsFilter);
+
+/* Gets the value of given name from the query string in url */
+function getParameterByName(name) {
+  var match = RegExp('[?&]' + name + '=([^&]*)').exec(window.location.search);
+  return match && decodeURIComponent(match[1].replace(/\+/g, ' '));
+}
+
+
+/* Updates the content of BULK_ACTION_NUM_PAGES_IN_LISTING_SPAN with the new count of pages */
+function udpateNumPagesInListing(filterQueryString) {
+  // eslint-disable-next-line no-undef
+  $.ajax({
+    url: 'filter-count/',
+    data: { filters: filterQueryString },
+    success: (response) => {
+      document.querySelector(`.${BULK_ACTION_NUM_PAGES_IN_LISTING_SPAN}`).textContent = response.count;
+    }
+  });
+}
+
+/* Event listener for filter dropdown options */
+function FilterEventListener(e) {
+  e.preventDefault();
+  const filter = e.target.dataset.filter || '';
+  udpateNumPagesInListing(filter);
+  const changeEvent = new Event('change');
+  if (filter.length) {
+    /* split the filter string into [key,value] pairs and check for the values in the
+        BULK_ACTION_PAGE_CHECKBOX_INPUT dataset */
+    const [_key, value] = filter.split(':');
+    const key = _key[0].toUpperCase() + _key.slice(1);
+    document.querySelectorAll(`.${BULK_ACTION_PAGE_CHECKBOX_INPUT}`).forEach(el => {
+      if (`page${key}` in el.dataset) {
+        if (el.dataset[`page${key}`] === value) {
+          if (!el.checked) {
+            el.checked = true;
+            el.dispatchEvent(changeEvent);
+          }
+        } else {
+          if (el.checked) {
+            el.checked = false;
+            el.dispatchEvent(changeEvent);
+          }
+        }
+      }
+    })
+  } else {
+    /* If filter string is empty, select all checkboxes */
+    document.querySelector(`.${BULK_ACTION_SELECT_ALL_CHECKBOX_TH}`).checked = true;
+    document.querySelector(`.${BULK_ACTION_SELECT_ALL_CHECKBOX_TH}`).dispatchEvent(changeEvent);
+  }
+}
+
+/* Event listener for bulk actions which appends selected page ids to the corresponding action url */
+function BulkActionEventListeners(e) {
+  e.preventDefault();
+  const url = e.target.getAttribute('href');
+  let queryString = '';
+  checkedState.checkedPages.forEach(pageId => {
+    queryString += `&id=${pageId}`;
+  });
+  window.location.href = url + queryString;
+}
+
+
+/* Adds all event listeners */
+function AddBulkActionEventListeners() {
+  document.querySelectorAll(`.${BULK_ACTION_PAGE_CHECKBOX_INPUT}`)
+    .forEach(el => {
+      checkedState.numPages++;
+      el.addEventListener('change', SelectBulkActionsCheckboxes);
+    });
+  document.querySelector(`.${BULK_ACTION_SELECT_ALL_CHECKBOX_TH}`).addEventListener('change', SelectBulkActionsFilter);
+  document.querySelectorAll(`.${BULK_ACTION_FILTERS_CLASS}`).forEach(
+    elem => elem.addEventListener('click', FilterEventListener)
+  );
+  document.querySelectorAll(`.${BULK_ACTION_CHOICES_DIV} > ul > li > a`).forEach(
+    elem => elem.addEventListener('click', BulkActionEventListeners)
+  );
+  udpateNumPagesInListing(getParameterByName('filters'));
 }
 
-window.AddBulkActionCheckboxEventListeners = AddBulkActionCheckboxEventListeners;
+window.AddBulkActionEventListeners = AddBulkActionEventListeners;

+ 33 - 0
wagtail/admin/templates/wagtailadmin/pages/bulk_actions/confirm_bulk_delete.html

@@ -0,0 +1,33 @@
+{% extends "wagtailadmin/base.html" %}
+{% load i18n wagtailadmin_tags %}
+{% block titletag %}{% blocktrans count counter=pages|length with title=page.get_admin_display_title  %}Delete 1 page {% plural %}Delete {{ counter }} pages{% endblocktrans %}{% endblock %}
+
+{% block content %}
+    {% trans "Delete" as del_str %}
+    {% include "wagtailadmin/shared/header.html" with title=del_str subtitle=page.get_admin_display_title icon="doc-empty-inverse" %}
+
+    <div class="nice-padding">
+        <p>{% blocktrans with snippet_type_name=model_opts.verbose_name_plural %}Are you sure you want to delete these pages?{% endblocktrans %}</p>
+        <ul>
+            {% for page in pages %}
+            <li>
+                <a href="{% url 'wagtailadmin_pages:edit' page.page.id %}" target="_blank" rel="noopener noreferrer">{{ page.page.title }}</a>
+                <ul>
+                    {% if page.descendant_count %}
+                        {% blocktrans count counter=page.descendant_count %}
+                            <li>This will also delete one more subpage.</li>
+                        {% plural %}
+                            <li>This will also delete {{ counter }} more subpages.</li>
+                        {% endblocktrans %}
+                    {% endif %}
+                </ul>
+            </li>
+            {% endfor %}
+        </ul>
+        <form action="{{ submit_url }}" method="POST">
+            {% csrf_token %}
+            <input type="submit" value="{% trans 'Yes, delete' %}" class="button serious" />
+            <a href="{{ next }}" class="button button-secondary">{% trans "No, don't delete" %}</a>
+        </form>
+    </div>
+{% endblock %}

+ 46 - 0
wagtail/admin/templates/wagtailadmin/pages/bulk_actions/confirm_bulk_publish.html

@@ -0,0 +1,46 @@
+{% extends "wagtailadmin/base.html" %}
+{% load i18n %}
+{% block titletag %}{% blocktrans count counter=pages|length with title=page.get_admin_display_title %}Publish 1 page {% plural %}Publish {{ counter }} pages{% endblocktrans %}{% endblock %}
+{% block content %}
+    {% trans "Unpublish" as unpublish_str %}
+    {% include "wagtailadmin/shared/header.html" with title=unpublish_str subtitle=page.get_admin_display_title icon="doc-empty-inverse" %}
+
+    <div class="nice-padding">
+        <p>{% trans "Are you sure you want to publish these pages?" %}</p>
+        <ul>
+            {% for page in pages %}
+            <li>
+                <a href="{% url 'wagtailadmin_pages:edit' page.page.id %}" target="_blank" rel="noopener noreferrer">{{ page.page.title }}</a>
+                <ul>
+                    {% if page.draft_descendant_count %}
+                        {% blocktrans count counter=page.draft_descendant_count %}
+                            <li>This page has one unpublished subpage</li>
+                        {% plural %}
+                            <li>This page has {{ counter }} unpublished subpages</li>
+                        {% endblocktrans %}
+                    {% endif %}
+                </ul>
+            </li>
+            {% endfor %}
+        </ul>
+        <form action="{{ submit_url }}" method="POST">
+            {% csrf_token %}
+            {% if has_draft_descendants %}
+            <ul class="fields">
+                <li>
+                    <div class="field boolean_field checkbox_input">
+                        <div class="field-content">
+                            <div class="input">
+                                <input id="id_include_descendants" name="include_descendants" type="checkbox">
+                                <label for="id_include_descendants" class="plain-checkbox-label">{% trans "Publish subpages" %}</label>
+                            </div>
+                        </div>
+                    </div>
+                </li>
+            </ul>
+            {% endif %}
+            <input type="submit" value="{% trans 'Yes, publish' %}" class="button" />
+            <a href="{{ next }}" class="button button-secondary">{% trans "No, don't publish" %}</a>
+        </form>
+    </div>
+{% endblock %}

+ 46 - 0
wagtail/admin/templates/wagtailadmin/pages/bulk_actions/confirm_bulk_unpublish.html

@@ -0,0 +1,46 @@
+{% extends "wagtailadmin/base.html" %}
+{% load i18n %}
+{% block titletag %}{% blocktrans count counter=pages|length with title=page.get_admin_display_title %}Unpublish 1 page {% plural %}Unpublish {{ counter }} pages{% endblocktrans %}{% endblock %}
+{% block content %}
+    {% trans "Unpublish" as unpublish_str %}
+    {% include "wagtailadmin/shared/header.html" with title=unpublish_str subtitle=page.get_admin_display_title icon="doc-empty-inverse" %}
+
+    <div class="nice-padding">
+        <p>{% trans "Are you sure you want to unpublish these pages?" %}</p>
+        <ul>
+            {% for page in pages %}
+            <li>
+                <a href="{% url 'wagtailadmin_pages:edit' page.page.id %}" target="_blank" rel="noopener noreferrer">{{ page.page.title }}</a>
+                <ul>
+                    {% if page.live_descendant_count %}
+                        {% blocktrans count counter=page.live_descendant_count %}
+                            <li>This page has one subpage</li>
+                        {% plural %}
+                            <li>This page has {{ counter }} subpages</li>
+                        {% endblocktrans %}
+                    {% endif %}
+                </ul>
+            </li>
+            {% endfor %}
+        </ul>
+        <form action="{{ submit_url }}" method="POST">
+            {% csrf_token %}
+            {% if has_live_descendants %}
+            <ul class="fields">
+                <li>
+                    <div class="field boolean_field checkbox_input">
+                        <div class="field-content">
+                            <div class="input">
+                                <input id="id_include_descendants" name="include_descendants" type="checkbox">
+                                <label for="id_include_descendants" class="plain-checkbox-label">{% trans "Unpublish subpages" %}</label>
+                            </div>
+                        </div>
+                    </div>
+                </li>
+            </ul>                
+            {% endif %}
+            <input type="submit" value="{% trans 'Yes, unpublish' %}" class="button" />
+            <a href="{{ next }}" class="button button-secondary">{% trans "No, don't unpublish" %}</a>
+        </form>
+    </div>
+{% endblock %}

+ 1 - 0
wagtail/admin/templates/wagtailadmin/pages/index.html

@@ -9,6 +9,7 @@
 
         {% explorer_breadcrumb parent_page %}
     </header>
+    <form method="GET" id="filter_search"></form>
 
     <form id="page-reorder-form">
         {% csrf_token %}

+ 1 - 1
wagtail/admin/templates/wagtailadmin/pages/listing/_button_with_dropdown.html

@@ -10,7 +10,7 @@
         <ul class="c-dropdown__menu u-toggle  u-arrow u-arrow--tl u-background">
         {% for button in buttons %}
             <li class="c-dropdown__item ">
-                <a href="{{ button.url }}" aria-label="{{ button.attrs.title }}" class="u-link is-live {{ button.classes|join:' ' }}">
+                <a href="{{ button.url }}" {% if button.attrs.filter %}data-filter={{button.attrs.filter}}{% endif %} aria-label="{{ button.attrs.title }}" class="u-link is-live {{ button.classes|join:' ' }}">
                     {{ button.label }}
                 </a>
             </li>

+ 2 - 2
wagtail/admin/templates/wagtailadmin/pages/listing/_list.html

@@ -46,7 +46,7 @@
                 {% page_permissions page as page_perms %}
                 <tr {% if ordering == "ord" %}id="page_{{ page.id|unlocalize }}" data-page-title="{{ page.get_admin_display_title }}"{% endif %} class="{% if not page.live %}unpublished{% endif %} {% block page_row_classname %}{% endblock %}">
                     <td>
-                        <input data-page-id="{{page.id}}" class="bulk-action-checkbox" aria-label="Page select checkbox" type="checkbox" />
+                        <input data-page-status="{% if page.live %}live{% else %}draft{% endif %}" data-page-id="{{page.id}}" class="bulk-action-checkbox" aria-label="Page select checkbox" type="checkbox" />
                     </td>
                     <td class="title" valign="top" data-listing-page-title>
                         {% block page_title %}
@@ -80,5 +80,5 @@
 
 <script src="{% versioned_static 'wagtailadmin/js/bulk-actions.js' %}"></script>
 <script>
-    window.AddBulkActionCheckboxEventListeners()
+    window.AddBulkActionEventListeners()
 </script>

+ 4 - 4
wagtail/admin/templates/wagtailadmin/pages/listing/_table_headers_explore.html

@@ -22,16 +22,16 @@ ordering: the current sort parameter
             <input type="checkbox" aria-label="Bulk action checkbox" />
             {% bulk_action_filters %}
             <div class="bulk-actions-choices u-hidden">
-                <span>All 8 on this page selected. Select all 43 in listing |</span>
-                <ul>{% bulk_action_choices %}</ul>
+                <span><span class="num-pages">All {{pages|length}}</span> on this page selected. Select all <span class="num-pages-in-listing">0</span> in listing |</span>
+                <ul>{% bulk_action_choices parent_page %}</ul>
             </div>
         </div>
     </th>
     <th class="search">
         <div class="nav-search">
-            <button class="button" type="submit">Search</button>
+            <button class="button" form="filter_search" type="submit">Search</button>
             <label for="page-search-q">Search</label>
-            <input aria-label="Search text" type="text" id="page-search" name="q" placeholder="Search">
+            <input aria-label="Search text" type="text" form="filter_search" value="{{filter_query}}" name="filters" placeholder="Search">
         </div>
     </th>
     {% if show_parent %}

+ 3 - 2
wagtail/admin/templatetags/wagtailadmin_tags.py

@@ -502,12 +502,13 @@ def bulk_action_filters(context):
 
 @register.inclusion_tag("wagtailadmin/pages/listing/_buttons.html",
                         takes_context=True)
-def bulk_action_choices(context):
+def bulk_action_choices(context, page):
+    next_url = context.request.path
     button_hooks = hooks.get_hooks('register_bulk_action_choices')
 
     buttons = []
     for hook in button_hooks:
-        buttons.extend(hook())
+        buttons.extend(hook(page, next_url=next_url))
 
     buttons.sort()
 

+ 7 - 0
wagtail/admin/urls/__init__.py

@@ -16,6 +16,7 @@ from wagtail.admin.urls import password_reset as wagtailadmin_password_reset_url
 from wagtail.admin.urls import reports as wagtailadmin_reports_urls
 from wagtail.admin.urls import workflows as wagtailadmin_workflows_urls
 from wagtail.admin.views import account, chooser, home, tags, userbar
+from wagtail.admin.views import bulk_actions
 from wagtail.admin.views.pages import listing
 from wagtail.core import hooks
 from wagtail.utils.urlpatterns import decorate_urlpatterns
@@ -33,6 +34,12 @@ urlpatterns = [
     # TODO: Move into wagtailadmin_pages namespace
     path('pages/', listing.index, name='wagtailadmin_explore_root'),
     path('pages/<int:parent_page_id>/', listing.index, name='wagtailadmin_explore'),
+    path('pages/<int:parent_page_id>/filter-count/', listing.filter_count, name='wagtailadmin_filter_count'),
+    
+    # bulk actions
+    path('pages/<int:parent_page_id>/multiple/delete/', bulk_actions.delete, name='wagtailadmin_bulk_delete'),
+    path('pages/<int:parent_page_id>/multiple/unpublish/', bulk_actions.unpublish, name='wagtailadmin_bulk_unpublish'),
+    path('pages/<int:parent_page_id>/multiple/publish/', bulk_actions.publish, name='wagtailadmin_bulk_publish'),
 
     path('pages/', include(wagtailadmin_pages_urls, namespace='wagtailadmin_pages')),
 

+ 3 - 0
wagtail/admin/views/bulk_actions/__init__.py

@@ -0,0 +1,3 @@
+from .delete import delete
+from .unpublish import unpublish
+from .publish import publish

+ 66 - 0
wagtail/admin/views/bulk_actions/delete.py

@@ -0,0 +1,66 @@
+from urllib.parse import urlencode
+
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import redirect, get_list_or_404
+from django.db import transaction
+from django.urls import reverse
+from django.template.response import TemplateResponse
+from django.utils.translation import gettext as _
+
+from wagtail.admin import messages
+from wagtail.admin.views.pages.utils import get_valid_next_url_from_request
+from wagtail.core import hooks
+from wagtail.core.models import Page
+
+
+def delete(request, parent_page_id):
+    next_url = get_valid_next_url_from_request(request)
+    if not next_url:
+        next_url = reverse('wagtailadmin_explore', args=[parent_page_id])
+    
+    page_ids = list(map(int, request.GET.getlist('id')))
+    pages = []
+
+    for page in get_list_or_404(Page, id__in=page_ids):
+        page = page.specific
+        if not page.permissions_for_user(request.user).can_delete():
+            raise PermissionDenied
+        pages.append(page)
+    
+    if request.method == 'GET':
+        _pages = []
+        for page in pages:
+            _pages.append({
+                'page': page,
+                'descendant_count': page.get_descendant_count(),
+            })
+
+        return TemplateResponse(request, 'wagtailadmin/pages/bulk_actions/confirm_bulk_delete.html', {
+            'pages': _pages,
+            'next': next_url,
+            'submit_url': (
+                reverse('wagtailadmin_bulk_delete', args=[parent_page_id])
+                + '?' + urlencode([('id', page_id) for page_id in page_ids])
+            ),
+        })
+    elif request.method == 'POST':
+        num_parent_pages = 0
+        num_child_pages = 0
+        with transaction.atomic():
+            for page in pages:
+                num_parent_pages += 1
+                num_child_pages += page.get_descendant_count()
+                for fn in hooks.get_hooks('before_delete_page'):
+                    result = fn(request, page)
+                    if hasattr(result, 'status_code'):
+                        return result
+                page.delete(user=request.user)
+
+                for fn in hooks.get_hooks('after_delete_page'):
+                    result = fn(request, page)
+                    if hasattr(result, 'status_code'):
+                        return result
+
+        messages.success(request, _(f'You have successfully deleted {num_parent_pages} pages including '
+                                        '{num_child_pages} child pages.'))
+    return redirect(next_url)

+ 79 - 0
wagtail/admin/views/bulk_actions/publish.py

@@ -0,0 +1,79 @@
+from urllib.parse import urlencode
+
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import get_list_or_404, redirect
+from django.template.response import TemplateResponse
+from django.urls import reverse
+from django.utils.translation import gettext as _
+from django.db import transaction
+
+from wagtail.admin import messages
+from wagtail.admin.views.pages.utils import get_valid_next_url_from_request
+from wagtail.core import hooks
+from wagtail.core.models import Page
+
+
+def publish(request, parent_page_id):
+    next_url = get_valid_next_url_from_request(request)
+    if not next_url:
+        next_url = reverse('wagtailadmin_explore', args=[parent_page_id])
+    
+    page_ids = list(map(int, request.GET.getlist('id')))
+    pages = []
+
+    for _page in get_list_or_404(Page, id__in=page_ids):
+        page = _page.specific
+        if not page.permissions_for_user(request.user).can_publish():
+            raise PermissionDenied
+        pages.append(page)
+
+    if request.method == 'GET':
+        _pages = []
+        for page in pages:
+            _pages.append({
+                'page': page,
+                'draft_descendant_count': page.get_descendants().not_live().count(),
+            })
+
+        return TemplateResponse(request, 'wagtailadmin/pages/bulk_actions/confirm_bulk_publish.html', {
+            'pages': _pages,
+            'next': next_url,
+            'submit_url': (
+                reverse('wagtailadmin_bulk_publish', args=[parent_page_id])
+                + '?' + urlencode([('id', page_id) for page_id in page_ids])
+            ),
+            'has_draft_descendants': any(map(lambda x: x['draft_descendant_count'] > 0, _pages))
+        })
+    elif request.method == 'POST':
+        num_parent_pages = 0
+        include_descendants = request.POST.get("include_descendants", False)
+        if include_descendants:
+            num_child_pages = 0
+        with transaction.atomic():
+            for page in pages:
+                for fn in hooks.get_hooks('before_publish_page'):
+                    result = fn(request, page)
+                    if hasattr(result, 'status_code'):
+                        return result
+
+                revision = page.save_revision(user=request.user)
+                revision.publish(user=request.user)
+                num_parent_pages += 1
+
+                if include_descendants:
+                    for draft_descendant_page in page.get_descendants().not_live().defer_streamfields().specific():
+                        if draft_descendant_page.permissions_for_user(request.user).can_publish():
+                            revision = draft_descendant_page.save_revision(user=request.user)
+                            revision.publish(user=request.user)
+                            num_child_pages += 1
+
+                for fn in hooks.get_hooks('after_publish_page'):
+                    result = fn(request, page)
+                    if hasattr(result, 'status_code'):
+                        return result
+
+        if include_descendants:
+            messages.success(request, _(f'You have published {num_parent_pages} pages including {num_child_pages} child pages.'))
+        else:
+            messages.success(request, _(f'You have published {num_parent_pages} pages.'))
+    return redirect(next_url)

+ 79 - 0
wagtail/admin/views/bulk_actions/unpublish.py

@@ -0,0 +1,79 @@
+from urllib.parse import urlencode
+
+from django.core.exceptions import PermissionDenied
+from django.shortcuts import get_list_or_404, redirect
+from django.template.response import TemplateResponse
+from django.urls import reverse
+from django.utils.translation import gettext as _
+from django.db import transaction
+
+from wagtail.admin import messages
+from wagtail.admin.views.pages.utils import get_valid_next_url_from_request
+from wagtail.core import hooks
+from wagtail.core.models import Page, UserPagePermissionsProxy
+
+
+def unpublish(request, parent_page_id):
+    next_url = get_valid_next_url_from_request(request)
+    if not next_url:
+        next_url = reverse('wagtailadmin_explore', args=[parent_page_id])
+    
+    page_ids = list(map(int, request.GET.getlist('id')))
+    user_perms = UserPagePermissionsProxy(request.user)
+    pages = []
+
+    for page in get_list_or_404(Page, id__in=page_ids):
+        page = page.specific
+        if not user_perms.for_page(page).can_unpublish():
+            raise PermissionDenied
+        pages.append(page)
+        page.unpublish
+
+    if request.method == 'GET':
+        _pages = []
+        for page in pages:
+            _pages.append({
+                'page': page,
+                'live_descendant_count': page.get_descendants().live().count(),
+            })
+
+        return TemplateResponse(request, 'wagtailadmin/pages/bulk_actions/confirm_bulk_unpublish.html', {
+            'pages': _pages,
+            'next': next_url,
+            'submit_url': (
+                reverse('wagtailadmin_bulk_unpublish', args=[parent_page_id])
+                + '?' + urlencode([('id', page_id) for page_id in page_ids])
+            ),
+            'has_live_descendants': any(map(lambda x: x['live_descendant_count'] > 0, _pages)),
+        })
+    elif request.method == 'POST':
+        num_parent_pages = 0
+        include_descendants = request.POST.get("include_descendants", False)
+        if include_descendants:
+            num_child_pages = 0
+        with transaction.atomic():
+            for page in pages:
+                for fn in hooks.get_hooks('before_unpublish_page'):
+                    result = fn(request, page)
+                    if hasattr(result, 'status_code'):
+                        return result
+
+                page.unpublish(user=request.user)
+                num_parent_pages += 1
+
+                if include_descendants:
+                    for live_descendant_page in page.get_descendants().live().defer_streamfields().specific():
+                        if user_perms.for_page(live_descendant_page).can_unpublish():
+                            live_descendant_page.unpublish()
+                            num_child_pages += 1
+
+                for fn in hooks.get_hooks('after_unpublish_page'):
+                    result = fn(request, page)
+                    if hasattr(result, 'status_code'):
+                        return result
+
+        if include_descendants:
+            messages.success(request, _(f'You have unpublished {num_parent_pages} pages including {num_child_pages} child pages.'))
+        else:
+            messages.success(request, _(f'You have unpublished {num_parent_pages} pages.'))
+    return redirect(next_url)

+ 74 - 0
wagtail/admin/views/pages/listing.py

@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.core.paginator import Paginator
 from django.db.models import Count
+from django.http.response import JsonResponse
 from django.shortcuts import get_object_or_404, redirect
 from django.template.response import TemplateResponse
 from django.urls import reverse
@@ -11,6 +12,40 @@ from wagtail.core import hooks
 from wagtail.core.models import Page, UserPagePermissionsProxy
 
 
+def apply_filters(filter_query):
+    # filter_query should be of the format 'filter1:value1 filter2:value2'
+    filter_query = filter_query.strip()
+    if not filter_query:
+        return dict()
+    filter_query = filter_query.split(' ')
+    filters = {
+        'status': {
+            'live': dict(live=True),
+            'draft': dict(live=False),
+        }
+    }
+    filter_args = dict()  # will be of format { filter_name: value }
+    filters_to_be_applied = dict()
+    for _arg in filter_query:
+        try:
+            k, v = _arg.split(':')
+            filter_args[k] = v
+        except ValueError:
+            # in case filter_query has a wrong format, ignore the erroneous filter
+            continue
+    
+    filters_to_be_applied = {}
+
+    for _arg, filter_value in filter_args.items():
+        if _arg not in filters:
+            continue
+        if filter_value not in filters[_arg]:
+            continue
+        filters_to_be_applied.update(filters[_arg][filter_value])
+
+    return filters_to_be_applied
+
+
 @user_passes_test(user_has_any_page_permission)
 def index(request, parent_page_id=None):
     if parent_page_id:
@@ -39,6 +74,13 @@ def index(request, parent_page_id=None):
         & user_perms.explorable_pages()
     )
 
+    # filter pages
+    # the query should be ?filters=filter1:value1 filter2:value2.... If there are duplicate filters, the filter value of the
+    # latter will be considered by design
+    filter_query = request.GET.get('filters', '')
+    filters_to_be_applied = apply_filters(filter_query)
+    pages = pages.filter(**filters_to_be_applied)
+
     # Get page ordering
     ordering = request.GET.get('ordering', '-latest_revision_created_at')
     if ordering not in [
@@ -103,6 +145,7 @@ def index(request, parent_page_id=None):
         'do_paginate': do_paginate,
         'locale': None,
         'translations': [],
+        'filter_query': filter_query,
     }
 
     if getattr(settings, 'WAGTAIL_I18N_ENABLED', False) and not parent_page.is_root():
@@ -118,3 +161,34 @@ def index(request, parent_page_id=None):
         })
 
     return TemplateResponse(request, 'wagtailadmin/pages/index.html', context)
+
+
+@user_passes_test(user_has_any_page_permission)
+def filter_count(request, parent_page_id=None):
+    if parent_page_id:
+        parent_page = get_object_or_404(Page, id=parent_page_id)
+    else:
+        parent_page = Page.get_first_root_node()
+
+    # This will always succeed because of the @user_passes_test above.
+    root_page = get_explorable_root_page(request.user)
+
+    # If this page isn't a descendant of the user's explorable root page,
+    # then redirect to that explorable root page instead.
+    if not (
+        parent_page.pk == root_page.pk
+        or parent_page.is_descendant_of(root_page)
+    ):
+        return redirect('wagtailadmin_explore', root_page.pk)
+
+    parent_page = parent_page.specific
+
+    user_perms = UserPagePermissionsProxy(request.user)
+    pages = (
+        parent_page.get_children().prefetch_related(
+            "content_type", "sites_rooted_here"
+        )
+        & user_perms.explorable_pages()
+    )
+    filter_query = request.GET.dict().get('filters', 'is:page')
+    return JsonResponse(dict(count=pages.filter(**apply_filters(filter_query)).count()))

+ 13 - 13
wagtail/admin/wagtail_hooks.py

@@ -164,49 +164,49 @@ def register_workflow_tasks_menu_item():
 def bulk_action_filters():
     yield Button(
             _('All'),
-            '',
-            attrs={'aria-label': _("All pages")},
+            '?filters=',
+            attrs={'title': _("All pages"), 'filter': _("")},
             priority=10
         )
 
     yield Button(
             _('Status: Draft'),
-            '',
-            attrs={'aria-label': _("Draft pages")},
-            priority=10
+            '?filters=status:draft',
+            attrs={'title': _("Draft pages"), 'filter': _("status:draft")},
+            priority=20
         )
 
     yield Button(
             _('Status: Live'),
-            '',
-            attrs={'aria-label': _("Live pages")},
-            priority=10
+            '?filters=status:live',
+            attrs={'title': _("Live pages"), 'filter': _("status:live")},
+            priority=30
         )
 
 
 @hooks.register('register_bulk_action_choices')
-def bulk_action_choices(is_parent=False, next_url=None):
+def bulk_action_choices(page, is_parent=False, next_url=None):
     yield PageListingButton(
         _('Move'),
-        '',
+        reverse('wagtailadmin_bulk_delete', args=[page.id]) + '?' + urlencode({'next': next_url}),
         attrs={'aria-label': _("Move pages")},
         priority=10
     )
     yield PageListingButton(
         _('Publish'),
-        '',
+        reverse('wagtailadmin_bulk_publish', args=[page.id]) + '?' + urlencode({'next': next_url}),
         attrs={'aria-label': _("Publish pages")},
         priority=20
     )
     yield PageListingButton(
         _('Unpublish'),
-        '',
+        reverse('wagtailadmin_bulk_unpublish', args=[page.id]) + '?' + urlencode({'next': next_url}),
         attrs={'aria-label': _("Unpublish pages")},
         priority=30
     )
     yield PageListingButton(
         _('Delete'),
-        '',
+        reverse('wagtailadmin_bulk_delete', args=[page.id]) + '?' + urlencode({'next': next_url}),
         attrs={'aria-label': _("Delete pages")},
         priority=40
     )