Browse Source

Fixed #32204 -- Added quick filter to admin's navigation sidebar.

Maxim Milovanov 4 years ago
parent
commit
d915dd1c58

+ 22 - 0
django/contrib/admin/static/admin/css/nav_sidebar.css

@@ -118,3 +118,25 @@
         max-width: 100%;
     }
 }
+
+#nav-filter {
+    width: 100%;
+    box-sizing: border-box;
+    padding: 2px 5px;
+    margin: 5px 0;
+    border: 1px solid var(--border-color);
+    background-color: var(--darkened-bg);
+    color: var(--body-fg);
+}
+
+#nav-filter:focus {
+    border-color: var(--body-quiet-color);
+}
+
+#nav-filter.no-results {
+    background: var(--message-error-bg);
+}
+
+#nav-sidebar table {
+    width: 100%;
+}

+ 54 - 0
django/contrib/admin/static/admin/js/nav_sidebar.js

@@ -36,4 +36,58 @@
             main.classList.toggle('shifted');
         });
     }
+
+    function initSidebarQuickFilter() {
+        const options = [];
+        const navSidebar = document.getElementById('nav-sidebar');
+        if (!navSidebar) {
+            return;
+        }
+        navSidebar.querySelectorAll('th[scope=row] a').forEach((container) => {
+            options.push({title: container.innerHTML, node: container});
+        });
+
+        function checkValue(event) {
+            let filterValue = event.target.value;
+            if (filterValue) {
+                filterValue = filterValue.toLowerCase();
+            }
+            if (event.key === 'Escape') {
+                filterValue = '';
+                event.target.value = ''; // clear input
+            }
+            let matches = false;
+            for (const o of options) {
+                let displayValue = '';
+                if (filterValue) {
+                    if (o.title.toLowerCase().indexOf(filterValue) === -1) {
+                        displayValue = 'none';
+                    } else {
+                        matches = true;
+                    }
+                }
+                // show/hide parent <TR>
+                o.node.parentNode.parentNode.style.display = displayValue;
+            }
+            if (!filterValue || matches) {
+                event.target.classList.remove('no-results');
+            } else {
+                event.target.classList.add('no-results');
+            }
+            localStorage.setItem('django.admin.navSidebarFilterValue', filterValue);
+        }
+
+        const nav = document.getElementById('nav-filter');
+        nav.addEventListener('change', checkValue, false);
+        nav.addEventListener('input', checkValue, false);
+        nav.addEventListener('keyup', checkValue, false);
+
+        const storedValue = localStorage.getItem('django.admin.navSidebarFilterValue');
+        if (storedValue) {
+            nav.value = storedValue;
+            checkValue({target: nav, key: ''});
+        }
+    }
+    window.initSidebarQuickFilter = initSidebarQuickFilter;
+    initSidebarQuickFilter();
 }

+ 3 - 0
django/contrib/admin/templates/admin/nav_sidebar.html

@@ -1,5 +1,8 @@
 {% load i18n %}
 <button class="sticky toggle-nav-sidebar" id="toggle-nav-sidebar" aria-label="{% translate 'Toggle navigation' %}"></button>
 <nav class="sticky" id="nav-sidebar">
+  <input type="search" id="nav-filter"
+         placeholder="{% translate 'Start typing to filter...' %}"
+         aria-label="{% translate 'Filter navigation items' %}">
   {% include 'admin/app_list.html' with app_list=available_apps show_changelinks=False %}
 </nav>

+ 2 - 0
docs/releases/4.0.txt

@@ -70,6 +70,8 @@ Minor features
 * The new :meth:`.ModelAdmin.get_formset_kwargs` method allows customizing the
   keyword arguments passed to the constructor of a formset.
 
+* The navigation sidebar now has a quick filter toolbar.
+
 :mod:`django.contrib.admindocs`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 24 - 0
js_tests/admin/navigation.test.js

@@ -0,0 +1,24 @@
+/* global QUnit */
+'use strict';
+
+QUnit.module('admin.sidebar: filter', {
+    beforeEach: function() {
+        const $ = django.jQuery;
+        $('#qunit-fixture').append($('#nav-sidebar-filter').text());
+        this.navSidebar = $('#nav-sidebar');
+        this.navFilter = $('#nav-filter');
+        initSidebarQuickFilter();
+    }
+});
+
+QUnit.test('filter by a model name', function(assert) {
+    assert.equal(this.navSidebar.find('th[scope=row] a').length, 2);
+
+    this.navFilter.val('us'); // Matches 'users'.
+    this.navFilter[0].dispatchEvent(new Event('change'));
+    assert.equal(this.navSidebar.find('tr[class^="model-"]:visible').length, 1);
+
+    this.navFilter.val('nonexistent');
+    this.navFilter[0].dispatchEvent(new Event('change'));
+    assert.equal(this.navSidebar.find('tr[class^="model-"]:visible').length, 0);
+});

+ 30 - 0
js_tests/tests.html

@@ -83,6 +83,33 @@
             </div>
         </div>
     </script>
+    <script type="text/html" id="nav-sidebar-filter">
+      <nav class="sticky" id="nav-sidebar">
+        <input type="search" id="nav-filter"
+               placeholder="Start typing to filter..."
+               aria-label="Filter navigation items">
+        <div class="app-auth module current-app">
+          <table>
+            <caption>
+              <a href="/admin/auth/" class="section"
+                 title="Models in the Authentication and Authorization application">
+                    Authentication and Authorization
+              </a>
+            </caption>
+            <tbody>
+            <tr class="model-group">
+              <th scope="row"><a href="/admin/auth/group/">Groups</a></th>
+              <td><a href="/admin/auth/group/add/" class="addlink">Add</a></td>
+            </tr>
+            <tr class="model-user current-model">
+              <th scope="row"><a href="/admin/auth/user/" aria-current="page">Users</a></th>
+              <td><a href="/admin/auth/user/add/" class="addlink">Add</a></td>
+            </tr>
+            </tbody>
+          </table>
+        </div>
+      </nav>
+    </script>
 
     <script src="../node_modules/qunit/qunit/qunit.js"></script>
 
@@ -94,6 +121,9 @@
     <script src='../django/contrib/admin/static/admin/js/core.js' data-cover></script>
     <script src='./admin/core.test.js'></script>
 
+    <script src='../django/contrib/admin/static/admin/js/nav_sidebar.js' data-cover></script>
+    <script src='./admin/navigation.test.js'></script>
+
     <script src='../django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js' data-cover></script>
 
     <script src='./admin/DateTimeShortcuts.test.js'></script>

+ 13 - 0
tests/admin_views/test_nav_sidebar.py

@@ -143,3 +143,16 @@ class SeleniumTests(AdminSeleniumTestCase):
         self.selenium.get(self.live_server_url + reverse('test_with_sidebar:auth_user_changelist'))
         main_element = self.selenium.find_element_by_css_selector('#main')
         self.assertIn('shifted', main_element.get_attribute('class').split())
+
+    def test_sidebar_filter_persists(self):
+        self.selenium.get(
+            self.live_server_url +
+            reverse('test_with_sidebar:auth_user_changelist')
+        )
+        filter_value_script = (
+            "return localStorage.getItem('django.admin.navSidebarFilterValue')"
+        )
+        self.assertIsNone(self.selenium.execute_script(filter_value_script))
+        filter_input = self.selenium.find_element_by_css_selector('#nav-filter')
+        filter_input.send_keys('users')
+        self.assertEqual(self.selenium.execute_script(filter_value_script), 'users')

+ 2 - 2
tests/admin_views/tests.py

@@ -3142,7 +3142,7 @@ class AdminViewListEditable(TestCase):
         # CSRF field = 1
         # field to track 'select all' across paginated views = 1
         # 6 + 4 + 4 + 1 + 2 + 1 + 1 = 19 inputs
-        self.assertContains(response, "<input", count=19)
+        self.assertContains(response, "<input", count=20)
         # 1 select per object = 3 selects
         self.assertContains(response, "<select", count=4)
 
@@ -4980,7 +4980,7 @@ class ReadonlyTest(AdminFieldExtractionMixin, TestCase):
         self.assertNotContains(response, 'name="posted"')
         # 3 fields + 2 submit buttons + 5 inline management form fields, + 2
         # hidden fields for inlines + 1 field for the inline + 2 empty form
-        self.assertContains(response, "<input", count=15)
+        self.assertContains(response, "<input", count=16)
         self.assertContains(response, formats.localize(datetime.date.today()))
         self.assertContains(response, "<label>Awesomeness level:</label>")
         self.assertContains(response, "Very awesome.")