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

This commit is contained in:
Maxim Milovanov 2020-12-04 14:50:11 +03:00 committed by Mariusz Felisiak
parent 7248afe12f
commit d915dd1c58
8 changed files with 150 additions and 2 deletions

View File

@ -118,3 +118,25 @@
max-width: 100%; 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%;
}

View File

@ -36,4 +36,58 @@
main.classList.toggle('shifted'); 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();
} }

View File

@ -1,5 +1,8 @@
{% load i18n %} {% load i18n %}
<button class="sticky toggle-nav-sidebar" id="toggle-nav-sidebar" aria-label="{% translate 'Toggle navigation' %}"></button> <button class="sticky toggle-nav-sidebar" id="toggle-nav-sidebar" aria-label="{% translate 'Toggle navigation' %}"></button>
<nav class="sticky" id="nav-sidebar"> <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 %} {% include 'admin/app_list.html' with app_list=available_apps show_changelinks=False %}
</nav> </nav>

View File

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

View File

@ -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);
});

View File

@ -83,6 +83,33 @@
</div> </div>
</div> </div>
</script> </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> <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='../django/contrib/admin/static/admin/js/core.js' data-cover></script>
<script src='./admin/core.test.js'></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='../django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js' data-cover></script>
<script src='./admin/DateTimeShortcuts.test.js'></script> <script src='./admin/DateTimeShortcuts.test.js'></script>

View File

@ -143,3 +143,16 @@ class SeleniumTests(AdminSeleniumTestCase):
self.selenium.get(self.live_server_url + reverse('test_with_sidebar:auth_user_changelist')) self.selenium.get(self.live_server_url + reverse('test_with_sidebar:auth_user_changelist'))
main_element = self.selenium.find_element_by_css_selector('#main') main_element = self.selenium.find_element_by_css_selector('#main')
self.assertIn('shifted', main_element.get_attribute('class').split()) 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')

View File

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