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, "