mirror of
https://github.com/django/django.git
synced 2024-12-22 09:05:43 +00:00
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:
parent
d6aff369ad
commit
d24ba1be7a
1
AUTHORS
1
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
|
||||
|
@ -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):
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
100
django/contrib/admin/static/admin/css/nav_sidebar.css
Normal file
100
django/contrib/admin/static/admin/css/nav_sidebar.css
Normal 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;
|
||||
}
|
||||
}
|
22
django/contrib/admin/static/admin/js/nav_sidebar.js
Normal file
22
django/contrib/admin/static/admin/js/nav_sidebar.js
Normal 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');
|
||||
});
|
||||
}
|
||||
}
|
40
django/contrib/admin/templates/admin/app_list.html
Normal file
40
django/contrib/admin/templates/admin/app_list.html
Normal 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 don’t have permission to view or edit anything.' %}</p>
|
||||
{% endif %}
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -11,6 +11,8 @@
|
||||
|
||||
{% block nav-global %}{% endblock %}
|
||||
|
||||
{% block nav-sidebar %}{% endblock %}
|
||||
|
||||
{% block content_title %}{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}{% endblock %}
|
||||
|
4
django/contrib/admin/templates/admin/nav_sidebar.html
Normal file
4
django/contrib/admin/templates/admin/nav_sidebar.html
Normal 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>
|
@ -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>
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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(
|
||||
|
103
tests/admin_views/test_nav_sidebar.py
Normal file
103
tests/admin_views/test_nav_sidebar.py
Normal 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())
|
@ -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'])
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user