summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTom Carrick <knyght@knyg.ht>2020-05-06 10:29:51 +0200
committerMariusz Felisiak <felisiak.mariusz@gmail.com>2020-05-11 18:16:38 +0200
commitd24ba1be7a53a113d19e2860c03aff9922efec24 (patch)
tree50549f65e023c3ab94db2bc3b902405a58051f40
parentd6aff369ad33457ae2355b5b210faf1c4890ff35 (diff)
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>
-rw-r--r--AUTHORS1
-rw-r--r--django/contrib/admin/sites.py3
-rw-r--r--django/contrib/admin/static/admin/css/base.css21
-rw-r--r--django/contrib/admin/static/admin/css/login.css4
-rw-r--r--django/contrib/admin/static/admin/css/nav_sidebar.css100
-rw-r--r--django/contrib/admin/static/admin/js/nav_sidebar.js22
-rw-r--r--django/contrib/admin/templates/admin/app_list.html40
-rw-r--r--django/contrib/admin/templates/admin/base.html16
-rw-r--r--django/contrib/admin/templates/admin/index.html42
-rw-r--r--django/contrib/admin/templates/admin/login.html2
-rw-r--r--django/contrib/admin/templates/admin/nav_sidebar.html4
-rw-r--r--django/contrib/admin/templates/registration/logged_out.html2
-rw-r--r--docs/ref/contrib/admin/index.txt7
-rw-r--r--docs/releases/3.1.txt4
-rw-r--r--tests/admin_inlines/tests.py4
-rw-r--r--tests/admin_views/test_nav_sidebar.py103
-rw-r--r--tests/admin_views/tests.py6
17 files changed, 337 insertions, 44 deletions
diff --git a/AUTHORS b/AUTHORS
index 79676cb559..9165388f58 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -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
diff --git a/django/contrib/admin/sites.py b/django/contrib/admin/sites.py
index 116303c94a..5cdb2a0f72 100644
--- a/django/contrib/admin/sites.py
+++ b/django/contrib/admin/sites.py
@@ -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):
diff --git a/django/contrib/admin/static/admin/css/base.css b/django/contrib/admin/static/admin/css/base.css
index 8f7472f13b..c4285195fc 100644
--- a/django/contrib/admin/static/admin/css/base.css
+++ b/django/contrib/admin/static/admin/css/base.css
@@ -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 {
diff --git a/django/contrib/admin/static/admin/css/login.css b/django/contrib/admin/static/admin/css/login.css
index d88249bf28..062b36e051 100644
--- a/django/contrib/admin/static/admin/css/login.css
+++ b/django/contrib/admin/static/admin/css/login.css
@@ -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 {
diff --git a/django/contrib/admin/static/admin/css/nav_sidebar.css b/django/contrib/admin/static/admin/css/nav_sidebar.css
new file mode 100644
index 0000000000..6fcfb26131
--- /dev/null
+++ b/django/contrib/admin/static/admin/css/nav_sidebar.css
@@ -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;
+ }
+}
diff --git a/django/contrib/admin/static/admin/js/nav_sidebar.js b/django/contrib/admin/static/admin/js/nav_sidebar.js
new file mode 100644
index 0000000000..9b558ca2e7
--- /dev/null
+++ b/django/contrib/admin/static/admin/js/nav_sidebar.js
@@ -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');
+ });
+ }
+}
diff --git a/django/contrib/admin/templates/admin/app_list.html b/django/contrib/admin/templates/admin/app_list.html
new file mode 100644
index 0000000000..ea4a85bd0b
--- /dev/null
+++ b/django/contrib/admin/templates/admin/app_list.html
@@ -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 don’t have permission to view or edit anything.' %}</p>
+{% endif %}
diff --git a/django/contrib/admin/templates/admin/base.html b/django/contrib/admin/templates/admin/base.html
index 8d6425ad04..61b5a13d74 100644
--- a/django/contrib/admin/templates/admin/base.html
+++ b/django/contrib/admin/templates/admin/base.html
@@ -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>
diff --git a/django/contrib/admin/templates/admin/index.html b/django/contrib/admin/templates/admin/index.html
index 299c268669..b6e84b64ed 100644
--- a/django/contrib/admin/templates/admin/index.html
+++ b/django/contrib/admin/templates/admin/index.html
@@ -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 don’t have permission to view or edit anything.' %}</p>
-{% endif %}
+ {% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
</div>
{% endblock %}
diff --git a/django/contrib/admin/templates/admin/login.html b/django/contrib/admin/templates/admin/login.html
index 97eb439c84..7a192a4bdf 100644
--- a/django/contrib/admin/templates/admin/login.html
+++ b/django/contrib/admin/templates/admin/login.html
@@ -11,6 +11,8 @@
{% block nav-global %}{% endblock %}
+{% block nav-sidebar %}{% endblock %}
+
{% block content_title %}{% endblock %}
{% block breadcrumbs %}{% endblock %}
diff --git a/django/contrib/admin/templates/admin/nav_sidebar.html b/django/contrib/admin/templates/admin/nav_sidebar.html
new file mode 100644
index 0000000000..84956c1bd1
--- /dev/null
+++ b/django/contrib/admin/templates/admin/nav_sidebar.html
@@ -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>
diff --git a/django/contrib/admin/templates/registration/logged_out.html b/django/contrib/admin/templates/registration/logged_out.html
index 90bb8ee668..460e17eafd 100644
--- a/django/contrib/admin/templates/registration/logged_out.html
+++ b/django/contrib/admin/templates/registration/logged_out.html
@@ -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>
diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
index 89c204c177..e5ec730c82 100644
--- a/docs/ref/contrib/admin/index.txt
+++ b/docs/ref/contrib/admin/index.txt
@@ -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.
diff --git a/docs/releases/3.1.txt b/docs/releases/3.1.txt
index 35e46e5650..2d9ae7ed4c 100644
--- a/docs/releases/3.1.txt
+++ b/docs/releases/3.1.txt
@@ -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.
diff --git a/tests/admin_inlines/tests.py b/tests/admin_inlines/tests.py
index 79bd77fef1..91bb9465a8 100644
--- a/tests/admin_inlines/tests.py
+++ b/tests/admin_inlines/tests.py
@@ -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(
diff --git a/tests/admin_views/test_nav_sidebar.py b/tests/admin_views/test_nav_sidebar.py
new file mode 100644
index 0000000000..2225376cd5
--- /dev/null
+++ b/tests/admin_views/test_nav_sidebar.py
@@ -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())
diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py
index db96fbebef..03ec445eeb 100644
--- a/tests/admin_views/tests.py
+++ b/tests/admin_views/tests.py
@@ -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'])