Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,7 @@ answer newbie questions, and generally made Django that much better:
Scott Pashley <github@scottpashley.co.uk>
scott@staplefish.com
Sean Brant
Sean Helvey <me@seanhelvey.com>
Sebastian Hillig <sebastian.hillig@gmail.com>
Sebastian Spiegel <https://www.tivix.com/>
Segyo Myung <myungsekyo@gmail.com>
Expand Down
43 changes: 37 additions & 6 deletions django/contrib/admin/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from urllib.parse import urlsplit

from django import forms
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.contrib.admin import helpers, widgets
Expand Down Expand Up @@ -71,6 +72,7 @@
from django.views.generic import RedirectView

IS_POPUP_VAR = "_popup"
SOURCE_MODEL_VAR = "_source_model"
TO_FIELD_VAR = "_to_field"
IS_FACETS_VAR = "_facets"

Expand Down Expand Up @@ -1342,6 +1344,7 @@ def render_change_form(
"save_on_top": self.save_on_top,
"to_field_var": TO_FIELD_VAR,
"is_popup_var": IS_POPUP_VAR,
"source_model_var": SOURCE_MODEL_VAR,
"app_label": app_label,
}
)
Expand Down Expand Up @@ -1398,12 +1401,39 @@ def response_add(self, request, obj, post_url_continue=None):
else:
attr = obj._meta.pk.attname
value = obj.serializable_value(attr)
popup_response_data = json.dumps(
{
"value": str(value),
"obj": str(obj),
}
)
popup_response = {
"value": str(value),
"obj": str(obj),
}

# Find the optgroup for the new item, if available
source_model_name = request.POST.get(SOURCE_MODEL_VAR)

if source_model_name:
app_label, model_name = source_model_name.split(".", 1)
try:
source_model = apps.get_model(app_label, model_name)
except LookupError:
msg = _('The app "%s" could not be found.') % source_model_name
self.message_user(request, msg, messages.ERROR)
else:
source_admin = self.admin_site._registry[source_model]
form = source_admin.get_form(request)()
if self.opts.verbose_name_plural in form.fields:
field = form.fields[self.opts.verbose_name_plural]
for option_value, option_label in field.choices:
# Check if this is an optgroup (label is a sequence
# of choices rather than a single string value).
if isinstance(option_label, (list, tuple)):
# It's an optgroup:
# (group_name, [(value, label), ...])
optgroup_label = option_value
for choice_value, choice_display in option_label:
if choice_display == str(obj):
popup_response["optgroup"] = str(optgroup_label)
break

popup_response_data = json.dumps(popup_response)
return TemplateResponse(
request,
self.popup_response_template
Expand Down Expand Up @@ -1913,6 +1943,7 @@ def _changeform_view(self, request, object_id, form_url, extra_context):
"object_id": object_id,
"original": obj,
"is_popup": IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET,
"source_model": request.GET.get(SOURCE_MODEL_VAR),
"to_field": to_field,
"media": media,
"inline_admin_formsets": inline_formsets,
Expand Down
51 changes: 40 additions & 11 deletions django/contrib/admin/static/admin/js/SelectBox.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,45 @@
'use strict';
{
const getOptionGroupName = (option) => option.parentElement.label;
const SelectBox = {
cache: {},
init: function(id) {
const box = document.getElementById(id);
SelectBox.cache[id] = [];
const cache = SelectBox.cache[id];
for (const node of box.options) {
cache.push({value: node.value, text: node.text, displayed: 1});
const group = getOptionGroupName(node);
cache.push({group, value: node.value, text: node.text, displayed: 1});
}
// Only sort if there are any groups (to preserve existing behavior for non-grouped selects)
if (cache.some(item => item.group)) {
SelectBox.sort(id);
}
},
redisplay: function(id) {
// Repopulate HTML select box from cache
const box = document.getElementById(id);
const scroll_value_from_top = box.scrollTop;
box.innerHTML = '';
for (const node of SelectBox.cache[id]) {
if (node.displayed) {
const new_option = new Option(node.text, node.value, false, false);
let node = box;
let currentOptgroup = null;
for (const option of SelectBox.cache[id]) {
if (option.displayed) {
// Create a new optgroup when the group changes
if (option.group && option.group !== currentOptgroup) {
currentOptgroup = option.group;
node = document.createElement('optgroup');
node.setAttribute('label', option.group);
box.appendChild(node);
} else if (!option.group && currentOptgroup !== null) {
// Back to ungrouped options
currentOptgroup = null;
node = box;
}
const new_option = new Option(option.text, option.value, false, false);
// Shows a tooltip when hovering over the option
new_option.title = node.text;
box.appendChild(new_option);
new_option.title = option.text;
node.appendChild(new_option);
}
}
box.scrollTop = scroll_value_from_top;
Expand Down Expand Up @@ -57,7 +76,7 @@
cache.splice(delete_index, 1);
},
add_to_cache: function(id, option) {
SelectBox.cache[id].push({value: option.value, text: option.text, displayed: 1});
SelectBox.cache[id].push({group: option.group, value: option.value, text: option.text, displayed: 1});
},
cache_contains: function(id, value) {
// Check if an item is contained in the cache
Expand All @@ -73,10 +92,15 @@
for (const option of from_box.options) {
const option_value = option.value;
if (option.selected && SelectBox.cache_contains(from, option_value)) {
SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1});
const group = getOptionGroupName(option);
SelectBox.add_to_cache(to, {group, value: option_value, text: option.text, displayed: 1});
SelectBox.delete_from_cache(from, option_value);
}
}
// Only sort if there are any groups (to preserve existing behavior for non-grouped selects)
if (SelectBox.cache[to].some(item => item.group)) {
SelectBox.sort(to);
}
SelectBox.redisplay(from);
SelectBox.redisplay(to);
},
Expand All @@ -85,17 +109,22 @@
for (const option of from_box.options) {
const option_value = option.value;
if (SelectBox.cache_contains(from, option_value)) {
SelectBox.add_to_cache(to, {value: option_value, text: option.text, displayed: 1});
const group = getOptionGroupName(option);
SelectBox.add_to_cache(to, {group, value: option_value, text: option.text, displayed: 1});
SelectBox.delete_from_cache(from, option_value);
}
}
// Only sort if there are any groups (to preserve existing behavior for non-grouped selects)
if (SelectBox.cache[to].some(item => item.group)) {
SelectBox.sort(to);
}
SelectBox.redisplay(from);
SelectBox.redisplay(to);
},
sort: function(id) {
SelectBox.cache[id].sort(function(a, b) {
a = a.text.toLowerCase();
b = b.text.toLowerCase();
a = (a.group && a.group.toLowerCase() || '') + a.text.toLowerCase();
b = (b.group && b.group.toLowerCase() || '') + b.text.toLowerCase();
if (a > b) {
return 1;
}
Expand Down
15 changes: 12 additions & 3 deletions django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@
// Update SelectBox cache for related fields.
if (window.SelectBox !== undefined && !SelectBox.cache[currentSelect.id]) {
SelectBox.add_to_cache(select.id, option);
// Sort if there are any groups present
if (SelectBox.cache[select.id].some(item => item.group)) {
SelectBox.sort(select.id);
}
SelectBox.redisplay(select.id);
}
return;
Expand All @@ -123,7 +127,7 @@
});
}

function dismissAddRelatedObjectPopup(win, newId, newRepr) {
function dismissAddRelatedObjectPopup(win, newId, newRepr, optgroup) {
const name = removePopupIndex(win.name);
const elem = document.getElementById(name);
if (elem) {
Expand All @@ -143,8 +147,13 @@
} else {
const toId = name + "_to";
const toElem = document.getElementById(toId);
const o = new Option(newRepr, newId);
SelectBox.add_to_cache(toId, o);
const newOption = new Option(newRepr, newId);
newOption.group = optgroup;
SelectBox.add_to_cache(toId, newOption);
// Sort if there are any groups present
if (SelectBox.cache[toId].some(item => item.group)) {
SelectBox.sort(toId);
}
SelectBox.redisplay(toId);
if (toElem && toElem.nodeName.toUpperCase() === 'SELECT') {
const skipIds = [name + "_from"];
Expand Down
2 changes: 1 addition & 1 deletion django/contrib/admin/static/admin/js/popup_response.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
opener.dismissDeleteRelatedObjectPopup(window, initData.value);
break;
default:
opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj);
opener.dismissAddRelatedObjectPopup(window, initData.value, initData.obj, initData.optgroup);
break;
}
}
1 change: 1 addition & 0 deletions django/contrib/admin/templates/admin/change_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
<div>
{% if is_popup %}<input type="hidden" name="{{ is_popup_var }}" value="1">{% endif %}
{% if to_field %}<input type="hidden" name="{{ to_field_var }}" value="{{ to_field }}">{% endif %}
{% if source_model %}<input type="hidden" name="{{ source_model_var }}" value="{{ source_model }}">{% endif %}
{% if save_on_top %}{% block submit_buttons_top %}{% submit_row %}{% endblock %}{% endif %}
{% if errors %}
<p class="errornote">
Expand Down
2 changes: 2 additions & 0 deletions django/contrib/admin/views/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django.contrib.admin.options import (
IS_FACETS_VAR,
IS_POPUP_VAR,
SOURCE_MODEL_VAR,
TO_FIELD_VAR,
IncorrectLookupParameters,
ShowFacets,
Expand Down Expand Up @@ -49,6 +50,7 @@
SEARCH_VAR,
IS_FACETS_VAR,
IS_POPUP_VAR,
SOURCE_MODEL_VAR,
TO_FIELD_VAR,
)

Expand Down
10 changes: 9 additions & 1 deletion django/contrib/admin/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,16 +333,24 @@ def get_related_url(self, info, action, *args):
)

def get_context(self, name, value, attrs):
from django.contrib.admin.views.main import IS_POPUP_VAR, TO_FIELD_VAR
from django.contrib.admin.views.main import (
IS_POPUP_VAR,
SOURCE_MODEL_VAR,
TO_FIELD_VAR,
)

rel_opts = self.rel.model._meta
info = (rel_opts.app_label, rel_opts.model_name)
related_field_name = self.rel.get_related_field().name
app_label = self.rel.field.model._meta.app_label
model_name = self.rel.field.model._meta.model_name

url_params = "&".join(
"%s=%s" % param
for param in [
(TO_FIELD_VAR, related_field_name),
(IS_POPUP_VAR, 1),
(SOURCE_MODEL_VAR, f"{app_label}.{model_name}"),
]
)
context = {
Expand Down
4 changes: 4 additions & 0 deletions docs/releases/6.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ Minor features
* The admin site login view now redirects authenticated users to the next URL,
if available, instead of always redirecting to the admin index page.

* The admin's ``FilteredSelectMultiple`` widget now uses ``<optgroup>``\s to
preserve :ref:`named groups <field-choices-named-groups>` (e.g.
``choices=[("Group", [("1", "Item")]), ...]``).

:mod:`django.contrib.admindocs`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
Loading