diff options
| author | Tom Carrick <knyght@knyg.ht> | 2020-05-06 10:29:51 +0200 |
|---|---|---|
| committer | Mariusz Felisiak <felisiak.mariusz@gmail.com> | 2020-05-11 18:16:38 +0200 |
| commit | d24ba1be7a53a113d19e2860c03aff9922efec24 (patch) | |
| tree | 50549f65e023c3ab94db2bc3b902405a58051f40 | |
| parent | d6aff369ad33457ae2355b5b210faf1c4890ff35 (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-- | AUTHORS | 1 | ||||
| -rw-r--r-- | django/contrib/admin/sites.py | 3 | ||||
| -rw-r--r-- | django/contrib/admin/static/admin/css/base.css | 21 | ||||
| -rw-r--r-- | django/contrib/admin/static/admin/css/login.css | 4 | ||||
| -rw-r--r-- | django/contrib/admin/static/admin/css/nav_sidebar.css | 100 | ||||
| -rw-r--r-- | django/contrib/admin/static/admin/js/nav_sidebar.js | 22 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/app_list.html | 40 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/base.html | 16 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/index.html | 42 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/login.html | 2 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/nav_sidebar.html | 4 | ||||
| -rw-r--r-- | django/contrib/admin/templates/registration/logged_out.html | 2 | ||||
| -rw-r--r-- | docs/ref/contrib/admin/index.txt | 7 | ||||
| -rw-r--r-- | docs/releases/3.1.txt | 4 | ||||
| -rw-r--r-- | tests/admin_inlines/tests.py | 4 | ||||
| -rw-r--r-- | tests/admin_views/test_nav_sidebar.py | 103 | ||||
| -rw-r--r-- | tests/admin_views/tests.py | 6 |
17 files changed, 337 insertions, 44 deletions
@@ -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']) |
