mirror of
https://github.com/django/django.git
synced 2024-12-22 17:16:24 +00:00
Fixed #32204 -- Added quick filter to admin's navigation sidebar.
This commit is contained in:
parent
7248afe12f
commit
d915dd1c58
@ -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%;
|
||||||
|
}
|
||||||
|
@ -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();
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
|
@ -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`
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
24
js_tests/admin/navigation.test.js
Normal file
24
js_tests/admin/navigation.test.js
Normal 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);
|
||||||
|
});
|
@ -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>
|
||||||
|
@ -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')
|
||||||
|
@ -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.")
|
||||||
|
Loading…
Reference in New Issue
Block a user