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
+ 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 %}
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 @@
+
@@ -94,6 +121,9 @@
+
+
+
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, "Awesomeness level:")
self.assertContains(response, "Very awesome.")