From 5c73fbb6a93ee214678f02ba4027f18dff49337b Mon Sep 17 00:00:00 2001
From: Carlton Gibson <carlton.gibson@noumenal.es>
Date: Thu, 15 Apr 2021 17:15:28 +0200
Subject: [PATCH] Fixed #32647 -- Restored multi-row select with shift-modifier
 in admin changelist.

Regression in 30e59705fc3e3e9e8370b965af794ad6173bf92b.
---
 .../contrib/admin/static/admin/js/actions.js  | 28 ++++++++++++++++++-
 docs/releases/3.2.1.txt                       |  3 ++
 tests/admin_changelist/tests.py               | 22 +++++++++++++++
 3 files changed, 52 insertions(+), 1 deletion(-)

diff --git a/django/contrib/admin/static/admin/js/actions.js b/django/contrib/admin/static/admin/js/actions.js
index 239aa5dc39..5aee34ea5e 100644
--- a/django/contrib/admin/static/admin/js/actions.js
+++ b/django/contrib/admin/static/admin/js/actions.js
@@ -88,6 +88,16 @@
     window.Actions = function(actionCheckboxes, options) {
         options = Object.assign({}, defaults, options);
         let list_editable_changed = false;
+        let lastChecked = null;
+        let shiftPressed = false;
+
+        document.addEventListener('keydown', (event) => {
+            shiftPressed = event.shiftKey;
+        });
+
+        document.addEventListener('keyup', (event) => {
+            shiftPressed = event.shiftKey;
+        });
 
         document.getElementById(options.allToggleId).addEventListener('click', function(event) {
             checker(actionCheckboxes, options, this.checked);
@@ -113,12 +123,28 @@
             });
         });
 
+        function affectedCheckboxes(target, withModifier) {
+            const multiSelect = (lastChecked && withModifier && lastChecked !== target);
+            if (!multiSelect) {
+                return [target];
+            }
+            const checkboxes = Array.from(actionCheckboxes);
+            const targetIndex = checkboxes.findIndex(el => el === target);
+            const lastCheckedIndex = checkboxes.findIndex(el => el === lastChecked);
+            const startIndex = Math.min(targetIndex, lastCheckedIndex);
+            const endIndex = Math.max(targetIndex, lastCheckedIndex);
+            const filtered = checkboxes.filter((el, index) => (startIndex <= index) && (index <= endIndex));
+            return filtered;
+        };
+
         Array.from(document.getElementById('result_list').tBodies).forEach(function(el) {
             el.addEventListener('change', function(event) {
                 const target = event.target;
                 if (target.classList.contains('action-select')) {
-                    target.closest('tr').classList.toggle(options.selectedClass, target.checked);
+                    const checkboxes = affectedCheckboxes(target, shiftPressed);
+                    checker(checkboxes, options, target.checked);
                     updateCounter(actionCheckboxes, options);
+                    lastChecked = target;
                 } else {
                     list_editable_changed = true;
                 }
diff --git a/docs/releases/3.2.1.txt b/docs/releases/3.2.1.txt
index 6b0b1576cf..76291f57c1 100644
--- a/docs/releases/3.2.1.txt
+++ b/docs/releases/3.2.1.txt
@@ -44,3 +44,6 @@ Bugfixes
 * Fixed a regression in Django 3.2 that caused a crash when decoding a cookie
   value, used by ``django.contrib.messages.storage.cookie.CookieStorage``, in
   the pre-Django 3.2 format (:ticket:`32643`).
+
+* Fixed a regression in Django 3.2 that stopped the shift-key modifier
+  selecting multiple rows in the admin changelist (:ticket:`32647`).
diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py
index 28acc401c1..8da1c2f799 100644
--- a/tests/admin_changelist/tests.py
+++ b/tests/admin_changelist/tests.py
@@ -1381,6 +1381,28 @@ class SeleniumTests(AdminSeleniumTestCase):
         self.assertIs(all_selector.get_property('checked'), False)
         self.assertEqual(row.get_attribute('class'), '')
 
+    def test_modifier_allows_multiple_section(self):
+        """
+        Selecting a row and then selecting another row whilst holding shift
+        should select all rows in-between.
+        """
+        from selenium.webdriver.common.action_chains import ActionChains
+        from selenium.webdriver.common.keys import Keys
+
+        Parent.objects.bulk_create([Parent(name='parent%d' % i) for i in range(5)])
+        self.admin_login(username='super', password='secret')
+        self.selenium.get(self.live_server_url + reverse('admin:admin_changelist_parent_changelist'))
+        checkboxes = self.selenium.find_elements_by_css_selector('tr input.action-select')
+        self.assertEqual(len(checkboxes), 5)
+        for c in checkboxes:
+            self.assertIs(c.get_property('checked'), False)
+        # Check first row. Hold-shift and check next-to-last row.
+        checkboxes[0].click()
+        ActionChains(self.selenium).key_down(Keys.SHIFT).click(checkboxes[-2]).key_up(Keys.SHIFT).perform()
+        for c in checkboxes[:-2]:
+            self.assertIs(c.get_property('checked'), True)
+        self.assertIs(checkboxes[-1].get_property('checked'), False)
+
     def test_select_all_across_pages(self):
         Parent.objects.bulk_create([Parent(name='parent%d' % i) for i in range(101)])
         self.admin_login(username='super', password='secret')