summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--django/contrib/admin/static/admin/css/nav_sidebar.css22
-rw-r--r--django/contrib/admin/static/admin/js/nav_sidebar.js54
-rw-r--r--django/contrib/admin/templates/admin/nav_sidebar.html3
-rw-r--r--docs/releases/4.0.txt2
-rw-r--r--js_tests/admin/navigation.test.js24
-rw-r--r--js_tests/tests.html30
-rw-r--r--tests/admin_views/test_nav_sidebar.py13
-rw-r--r--tests/admin_views/tests.py4
8 files changed, 150 insertions, 2 deletions
diff --git a/django/contrib/admin/static/admin/css/nav_sidebar.css b/django/contrib/admin/static/admin/css/nav_sidebar.css
index f3c2fd8042..e62838d6e3 100644
--- a/django/contrib/admin/static/admin/css/nav_sidebar.css
+++ b/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%;
+}
diff --git a/django/contrib/admin/static/admin/js/nav_sidebar.js b/django/contrib/admin/static/admin/js/nav_sidebar.js
index efaa7214b8..d262c26bb2 100644
--- a/django/contrib/admin/static/admin/js/nav_sidebar.js
+++ b/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();
}
diff --git a/django/contrib/admin/templates/admin/nav_sidebar.html b/django/contrib/admin/templates/admin/nav_sidebar.html
index 32c5b8f839..0a185e604f 100644
--- a/django/contrib/admin/templates/admin/nav_sidebar.html
+++ b/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>
diff --git a/docs/releases/4.0.txt b/docs/releases/4.0.txt
index b6f826f053..516f15b95e 100644
--- a/docs/releases/4.0.txt
+++ b/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`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/js_tests/admin/navigation.test.js b/js_tests/admin/navigation.test.js
new file mode 100644
index 0000000000..262f08c9ae
--- /dev/null
+++ b/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);
+});
diff --git a/js_tests/tests.html b/js_tests/tests.html
index 72a6eb4fcb..61bc4ac102 100644
--- a/js_tests/tests.html
+++ b/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>
diff --git a/tests/admin_views/test_nav_sidebar.py b/tests/admin_views/test_nav_sidebar.py
index fc1d70baee..04811489a7 100644
--- a/tests/admin_views/test_nav_sidebar.py
+++ b/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')
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index 94ddf2be34..69d03c3a95 100644
--- a/tests/admin_views/tests.py
+++ b/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.")