diff options
| -rw-r--r-- | django/contrib/admin/static/admin/css/nav_sidebar.css | 22 | ||||
| -rw-r--r-- | django/contrib/admin/static/admin/js/nav_sidebar.js | 54 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/nav_sidebar.html | 3 | ||||
| -rw-r--r-- | docs/releases/4.0.txt | 2 | ||||
| -rw-r--r-- | js_tests/admin/navigation.test.js | 24 | ||||
| -rw-r--r-- | js_tests/tests.html | 30 | ||||
| -rw-r--r-- | tests/admin_views/test_nav_sidebar.py | 13 | ||||
| -rw-r--r-- | tests/admin_views/tests.py | 4 |
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.") |
