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 tobias@neuyork.de Todd O'Bryan + Tom Carrick Tom Christie Tom Forbes 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 %} +
+ + + {% for model in app.models %} + + {% if model.admin_url %} + + {% else %} + + {% endif %} + + {% if model.add_url %} + + {% else %} + + {% endif %} + + {% if model.admin_url and show_changelinks %} + {% if model.view_only %} + + {% else %} + + {% endif %} + {% elif show_changelinks %} + + {% endif %} + + {% endfor %} +
+ {{ app.name }} +
{{ model.name }}{{ model.name }}{% translate 'Add' %}{% translate 'View' %}{% translate 'Change' %}
+
+ {% endfor %} +{% else %} +

{% translate 'You don’t have permission to view or edit anything.' %}

+{% 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 @@ {% block title %}{% endblock %} +{% if not is_popup and is_nav_sidebar_enabled %} + +{% endif %} {% block extrastyle %}{% endblock %} {% if LANGUAGE_BIDI %}{% endif %} {% block extrahead %}{% endblock %} @@ -64,6 +67,14 @@ {% endblock %} {% endif %} +
+ {% if not is_popup and is_nav_sidebar_enabled %} + {% block nav-sidebar %} + {% include "admin/nav_sidebar.html" %} + {% endblock %} + {% endif %} +
+ {% block messages %} {% if messages %}
    {% for message in messages %} @@ -86,8 +97,13 @@ {% block footer %}{% endblock %} +
+
+{% if not is_popup and is_nav_sidebar_enabled %} + +{% endif %} 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 %}
- -{% if app_list %} - {% for app in app_list %} -
- - - {% for model in app.models %} - - {% if model.admin_url %} - - {% else %} - - {% endif %} - - {% if model.add_url %} - - {% else %} - - {% endif %} - - {% if model.admin_url %} - {% if model.view_only %} - - {% else %} - - {% endif %} - {% else %} - - {% endif %} - - {% endfor %} -
- {{ app.name }} -
{{ model.name }}{{ model.name }}{% translate 'Add' %}{% translate 'View' %}{% translate 'Change' %}
-
- {% endfor %} -{% else %} -

{% translate 'You don’t have permission to view or edit anything.' %}

-{% endif %} + {% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
{% 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 @@ +
+ 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 %}{% endblock %} +{% block nav-sidebar %}{% endblock %} + {% block content %}

{% translate "Thanks for spending some quality time with the Web site today." %}

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, '