Fixed #31034 -- Added a navigation sidebar to the admin.

Co-authored-by: elky <elky@users.noreply.github.com>
Co-authored-by: Goetz <goetz.buerkle@gmail.com>
This commit is contained in:
Tom Carrick 2020-05-06 10:29:51 +02:00 committed by Mariusz Felisiak
parent d6aff369ad
commit d24ba1be7a
17 changed files with 337 additions and 44 deletions

View File

@ -884,6 +884,7 @@ answer newbie questions, and generally made Django that much better:
Tobias McNulty <https://www.caktusgroup.com/blog/>
tobias@neuyork.de
Todd O'Bryan <toddobryan@mac.com>
Tom Carrick <https://www.carrick.eu>
Tom Christie <tom@tomchristie.com>
Tom Forbes <tom@tomforb.es>
Tom Insam

View File

@ -50,6 +50,8 @@ class AdminSite:
# URL for the "View site" link at the top of each admin page.
site_url = '/'
enable_nav_sidebar = True
_empty_value_display = '-'
login_form = None
@ -309,6 +311,7 @@ class AdminSite:
'has_permission': self.has_permission(request),
'available_apps': self.get_app_list(request),
'is_popup': False,
'is_nav_sidebar_enabled': self.enable_nav_sidebar,
}
def password_change(self, request, extra_context=None):

View File

@ -4,6 +4,10 @@
@import url(fonts.css);
html, body {
height: 100%;
}
body {
margin: 0;
padding: 0;
@ -732,6 +736,23 @@ table#change-history tbody th {
width: 100%;
min-width: 980px;
padding: 0;
display: flex;
flex-direction: column;
height: 100%;
}
#container > div {
flex-shrink: 0;
}
#container > .main {
display: flex;
flex: 1 0 auto;
}
.main > .content {
flex: 1 0;
max-width: 100%;
}
#content {

View File

@ -1,7 +1,8 @@
/* LOGIN FORM */
body.login {
.login {
background: #f8f8f8;
height: auto;
}
.login #header {
@ -30,6 +31,7 @@ body.login {
width: 28em;
min-width: 300px;
margin: 100px auto;
height: auto;
}
.login #content-main {

View File

@ -0,0 +1,100 @@
.sticky {
position: sticky;
top: 0;
max-height: 100vh;
}
.toggle-nav-sidebar {
z-index: 20;
left: 0;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 23px;
width: 23px;
border-right: 1px solid #eaeaea;
background-color: #ffffff;
cursor: pointer;
font-size: 20px;
color: #447e9b;
}
[dir="rtl"] .toggle-nav-sidebar {
border-left: 1px solid #eaeaea;
border-right: 0;
}
.toggle-nav-sidebar:hover,
.toggle-nav-sidebar:focus {
background-color: #f6f6f6;
}
#nav-sidebar {
z-index: 15;
flex: 0 0 275px;
left: -276px;
margin-left: -276px;
border-top: 1px solid transparent;
border-right: 1px solid #eaeaea;
background-color: #ffffff;
overflow: auto;
}
[dir="rtl"] #nav-sidebar {
border-left: 1px solid #eaeaea;
border-right: 0;
left: 0;
margin-left: 0;
right: -276px;
margin-right: -276px;
}
.toggle-nav-sidebar::before {
content: '\00BB';
}
.main.shifted .toggle-nav-sidebar::before {
content: '\00AB';
}
.main.shifted > #nav-sidebar {
left: 24px;
margin-left: 0;
}
[dir="rtl"] .main.shifted > #nav-sidebar {
left: 0;
right: 24px;
margin-right: 0;
}
#nav-sidebar .module th {
width: 100%;
}
#nav-sidebar .module th,
#nav-sidebar .module caption {
padding-left: 16px;
}
[dir="rtl"] #nav-sidebar .module th,
[dir="rtl"] #nav-sidebar .module caption {
padding-left: 8px;
padding-right: 16px;
}
#nav-sidebar .current-app .section:link,
#nav-sidebar .current-app .section:visited {
color: #ffc;
font-weight: bold;
}
#nav-sidebar .current-model {
background: #ffc;
}
@media (max-width: 767px) {
#nav-sidebar, #toggle-nav-sidebar {
display: none;
}
}

View File

@ -0,0 +1,22 @@
'use strict';
{
const toggleNavSidebar = document.getElementById('toggle-nav-sidebar');
if (toggleNavSidebar !== null) {
const main = document.getElementById('main');
let navSidebarIsOpen = localStorage.getItem('django.admin.navSidebarIsOpen');
if (navSidebarIsOpen === null) {
navSidebarIsOpen = 'true';
}
main.classList.toggle('shifted', navSidebarIsOpen === 'true');
toggleNavSidebar.addEventListener('click', function() {
if (navSidebarIsOpen == 'true') {
navSidebarIsOpen = 'false';
} else {
navSidebarIsOpen = 'true';
}
localStorage.setItem('django.admin.navSidebarIsOpen', navSidebarIsOpen);
main.classList.toggle('shifted');
});
}
}

View File

@ -0,0 +1,40 @@
{% load i18n %}
{% if app_list %}
{% for app in app_list %}
<div class="app-{{ app.app_label }} module{% if app.app_url in request.path %} current-app{% endif %}">
<table>
<caption>
<a href="{{ app.app_url }}" class="section" title="{% blocktranslate with name=app.name %}Models in the {{ name }} application{% endblocktranslate %}">{{ app.name }}</a>
</caption>
{% for model in app.models %}
<tr class="model-{{ model.object_name|lower }}{% if model.admin_url in request.path %} current-model{% endif %}">
{% if model.admin_url %}
<th scope="row"><a href="{{ model.admin_url }}"{% if model.admin_url in request.path %} aria-current="page"{% endif %}>{{ model.name }}</a></th>
{% else %}
<th scope="row">{{ model.name }}</th>
{% endif %}
{% if model.add_url %}
<td><a href="{{ model.add_url }}" class="addlink">{% translate 'Add' %}</a></td>
{% else %}
<td></td>
{% endif %}
{% if model.admin_url and show_changelinks %}
{% if model.view_only %}
<td><a href="{{ model.admin_url }}" class="viewlink">{% translate 'View' %}</a></td>
{% else %}
<td><a href="{{ model.admin_url }}" class="changelink">{% translate 'Change' %}</a></td>
{% endif %}
{% elif show_changelinks %}
<td></td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endfor %}
{% else %}
<p>{% translate 'You dont have permission to view or edit anything.' %}</p>
{% endif %}

View File

@ -4,6 +4,9 @@
<head>
<title>{% block title %}{% endblock %}</title>
<link rel="stylesheet" type="text/css" href="{% block stylesheet %}{% static "admin/css/base.css" %}{% endblock %}">
{% if not is_popup and is_nav_sidebar_enabled %}
<link rel="stylesheet" type="text/css" href="{% static "admin/css/nav_sidebar.css" %}">
{% endif %}
{% block extrastyle %}{% endblock %}
{% if LANGUAGE_BIDI %}<link rel="stylesheet" type="text/css" href="{% block stylesheet_rtl %}{% static "admin/css/rtl.css" %}{% endblock %}">{% endif %}
{% block extrahead %}{% endblock %}
@ -64,6 +67,14 @@
{% endblock %}
{% endif %}
<div class="main shifted" id="main">
{% if not is_popup and is_nav_sidebar_enabled %}
{% block nav-sidebar %}
{% include "admin/nav_sidebar.html" %}
{% endblock %}
{% endif %}
<div class="content">
{% block messages %}
{% if messages %}
<ul class="messagelist">{% for message in messages %}
@ -86,8 +97,13 @@
<!-- END Content -->
{% block footer %}<div id="footer"></div>{% endblock %}
</div>
</div>
</div>
<!-- END Container -->
{% if not is_popup and is_nav_sidebar_enabled %}
<script src="{% static 'admin/js/nav_sidebar.js' %}" async></script>
{% endif %}
</body>
</html>

View File

@ -9,47 +9,11 @@
{% block breadcrumbs %}{% endblock %}
{% block nav-sidebar %}{% endblock %}
{% block content %}
<div id="content-main">
{% if app_list %}
{% for app in app_list %}
<div class="app-{{ app.app_label }} module">
<table>
<caption>
<a href="{{ app.app_url }}" class="section" title="{% blocktranslate with name=app.name %}Models in the {{ name }} application{% endblocktranslate %}">{{ app.name }}</a>
</caption>
{% for model in app.models %}
<tr class="model-{{ model.object_name|lower }}">
{% if model.admin_url %}
<th scope="row"><a href="{{ model.admin_url }}">{{ model.name }}</a></th>
{% else %}
<th scope="row">{{ model.name }}</th>
{% endif %}
{% if model.add_url %}
<td><a href="{{ model.add_url }}" class="addlink">{% translate 'Add' %}</a></td>
{% else %}
<td></td>
{% endif %}
{% if model.admin_url %}
{% if model.view_only %}
<td><a href="{{ model.admin_url }}" class="viewlink">{% translate 'View' %}</a></td>
{% else %}
<td><a href="{{ model.admin_url }}" class="changelink">{% translate 'Change' %}</a></td>
{% endif %}
{% else %}
<td></td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
{% endfor %}
{% else %}
<p>{% translate 'You dont have permission to view or edit anything.' %}</p>
{% endif %}
{% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
</div>
{% endblock %}

View File

@ -11,6 +11,8 @@
{% block nav-global %}{% endblock %}
{% block nav-sidebar %}{% endblock %}
{% block content_title %}{% endblock %}
{% block breadcrumbs %}{% endblock %}

View File

@ -0,0 +1,4 @@
<div class="sticky toggle-nav-sidebar" id="toggle-nav-sidebar"></div>
<nav class="sticky" id="nav-sidebar">
{% include 'admin/app_list.html' with app_list=available_apps %}
</nav>

View File

@ -3,6 +3,8 @@
{% block breadcrumbs %}<div class="breadcrumbs"><a href="{% url 'admin:index' %}">{% translate 'Home' %}</a></div>{% endblock %}
{% block nav-sidebar %}{% endblock %}
{% block content %}
<p>{% translate "Thanks for spending some quality time with the Web site today." %}</p>

View File

@ -2841,6 +2841,13 @@ Templates can override or extend base admin templates as described in
setting an ``empty_value_display`` attribute on the field. See
:attr:`ModelAdmin.empty_value_display` for examples.
.. attribute:: AdminSite.enable_nav_sidebar
.. versionadded:: 3.1
A boolean value that determines whether to show the navigation sidebar
on larger screens. By default, it is set to ``True``.
.. attribute:: AdminSite.login_template
Path to a custom template that will be used by the admin site login view.

View File

@ -92,6 +92,10 @@ Minor features
* Filters in the right sidebar of the admin changelist view now contains a link
to clear all filters.
* The admin now has a sidebar on larger screens for easier navigation. It is
enabled by default but can be disabled by using a custom ``AdminSite`` and
setting :attr:`.AdminSite.enable_nav_sidebar` to ``False``.
* ``XRegExp`` is upgraded from version 2.0.0 to 3.2.0.
* jQuery is upgraded from version 3.4.1 to 3.5.1.

View File

@ -1331,7 +1331,9 @@ class SeleniumTests(AdminSeleniumTestCase):
hide_links = self.selenium.find_elements_by_link_text('HIDE')
self.assertEqual(len(hide_links), 2)
for hide_index, field_name in enumerate(test_fields):
hide_links[hide_index].click()
hide_link = hide_links[hide_index]
self.selenium.execute_script('window.scrollTo(0, %s);' % hide_link.location['y'])
hide_link.click()
self.wait_until_invisible(field_name)
self.selenium.find_element_by_xpath('//input[@value="Save"]').click()
self.assertEqual(

View File

@ -0,0 +1,103 @@
from django.contrib import admin
from django.contrib.admin.tests import AdminSeleniumTestCase
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from django.urls import path, reverse
class AdminSiteWithSidebar(admin.AdminSite):
pass
class AdminSiteWithoutSidebar(admin.AdminSite):
enable_nav_sidebar = False
site_with_sidebar = AdminSiteWithSidebar(name='test_with_sidebar')
site_without_sidebar = AdminSiteWithoutSidebar(name='test_without_sidebar')
site_with_sidebar.register(User)
urlpatterns = [
path('test_sidebar/admin/', site_with_sidebar.urls),
path('test_wihout_sidebar/admin/', site_without_sidebar.urls),
]
@override_settings(ROOT_URLCONF='admin_views.test_nav_sidebar')
class AdminSidebarTests(TestCase):
@classmethod
def setUpTestData(cls):
cls.superuser = User.objects.create_superuser(
username='super',
password='secret',
email='super@example.com',
)
def setUp(self):
self.client.force_login(self.superuser)
def test_sidebar_not_on_index(self):
response = self.client.get(reverse('test_with_sidebar:index'))
self.assertNotContains(response, '<nav class="sticky" id="nav-sidebar">')
def test_sidebar_disabled(self):
response = self.client.get(reverse('test_without_sidebar:index'))
self.assertNotContains(response, '<nav class="sticky" id="nav-sidebar">')
def test_sidebar_unauthenticated(self):
self.client.logout()
response = self.client.get(reverse('test_with_sidebar:login'))
self.assertNotContains(response, '<nav class="sticky" id="nav-sidebar">')
def test_sidebar_aria_current_page(self):
response = self.client.get(reverse('test_with_sidebar:auth_user_changelist'))
self.assertContains(response, '<nav class="sticky" id="nav-sidebar">')
self.assertContains(response, 'aria-current="page">Users</a>')
@override_settings(ROOT_URLCONF='admin_views.test_nav_sidebar')
class SeleniumTests(AdminSeleniumTestCase):
def setUp(self):
self.superuser = User.objects.create_superuser(
username='super',
password='secret',
email='super@example.com',
)
self.admin_login(username='super', password='secret', login_url=reverse('test_with_sidebar:index'))
self.selenium.execute_script("localStorage.removeItem('django.admin.navSidebarIsOpen')")
def test_sidebar_starts_open(self):
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_can_be_closed(self):
self.selenium.get(self.live_server_url + reverse('test_with_sidebar:auth_user_changelist'))
toggle_button = self.selenium.find_element_by_css_selector('#toggle-nav-sidebar')
toggle_button.click()
main_element = self.selenium.find_element_by_css_selector('#main')
self.assertNotIn('shifted', main_element.get_attribute('class').split())
def test_sidebar_state_persists(self):
self.selenium.get(self.live_server_url + reverse('test_with_sidebar:auth_user_changelist'))
self.assertIsNone(self.selenium.execute_script("return localStorage.getItem('django.admin.navSidebarIsOpen')"))
toggle_button = self.selenium.find_element_by_css_selector('#toggle-nav-sidebar')
toggle_button.click()
self.assertEqual(
self.selenium.execute_script("return localStorage.getItem('django.admin.navSidebarIsOpen')"),
'false',
)
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.assertNotIn('shifted', main_element.get_attribute('class').split())
toggle_button = self.selenium.find_element_by_css_selector('#toggle-nav-sidebar')
toggle_button.click()
self.assertEqual(
self.selenium.execute_script("return localStorage.getItem('django.admin.navSidebarIsOpen')"),
'true',
)
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())

View File

@ -5340,13 +5340,13 @@ class CSSTest(TestCase):
"""
# General index page
response = self.client.get(reverse('admin:index'))
self.assertContains(response, '<div class="app-admin_views module">')
self.assertContains(response, '<div class="app-admin_views module')
self.assertContains(response, '<tr class="model-actor">')
self.assertContains(response, '<tr class="model-album">')
# App index page
response = self.client.get(reverse('admin:app_list', args=('admin_views',)))
self.assertContains(response, '<div class="app-admin_views module">')
self.assertContains(response, '<div class="app-admin_views module')
self.assertContains(response, '<tr class="model-actor">')
self.assertContains(response, '<tr class="model-album">')
@ -6147,7 +6147,7 @@ class AdminViewOnSiteTests(TestCase):
response, 'inline_admin_formset', 0, None,
['Children must share a family name with their parents in this contrived test case']
)
msg = "The formset 'inline_admin_formset' in context 10 does not contain any non-form errors."
msg = "The formset 'inline_admin_formset' in context 12 does not contain any non-form errors."
with self.assertRaisesMessage(AssertionError, msg):
self.assertFormsetError(response, 'inline_admin_formset', None, None, ['Error'])