diff options
| author | seanhelvey <sean.helvey@gmail.com> | 2024-12-13 11:56:53 -0800 |
|---|---|---|
| committer | Jacob Walls <jacobtylerwalls@gmail.com> | 2026-01-22 21:12:23 -0500 |
| commit | b1ffa9a9d78b0c2c5ad6ed5a1d84e380d5cfd010 (patch) | |
| tree | 0fcfd9b90c788e21e58cb9249f4119062b8bfc4e /django/contrib/admin | |
| parent | 3851601b2e080df34fb9227fe5d2fd43af604263 (diff) | |
Fixed #13883 -- Rendered named choice groups with <optgroup> in FilteredSelectMultiple.
This patch adds support for <optgroup>s in FilteredSelectMultiple widgets.
When a popup returns a new object, if the source field contains optgroup
choices, the optgroup is now also included in the response data.
Additionally, this adds error handling for invalid source_model parameters
to prevent crashes and display user-friendly error messages instead.
Co-authored-by: Michael McLarnon <mmclar@gmail.com>
Diffstat (limited to 'django/contrib/admin')
| -rw-r--r-- | django/contrib/admin/options.py | 43 | ||||
| -rw-r--r-- | django/contrib/admin/static/admin/js/SelectBox.js | 51 | ||||
| -rw-r--r-- | django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js | 15 | ||||
| -rw-r--r-- | django/contrib/admin/static/admin/js/popup_response.js | 2 | ||||
| -rw-r--r-- | django/contrib/admin/templates/admin/change_form.html | 1 | ||||
| -rw-r--r-- | django/contrib/admin/views/main.py | 2 | ||||
| -rw-r--r-- | django/contrib/admin/widgets.py | 10 |
7 files changed, 102 insertions, 22 deletions
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 2de07fde7e..69b7031e82 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -8,6 +8,7 @@ from urllib.parse import quote as urlquote 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 @@ -71,6 +72,7 @@ from django.views.decorators.csrf import csrf_protect from django.views.generic import RedirectView IS_POPUP_VAR = "_popup" +SOURCE_MODEL_VAR = "_source_model" TO_FIELD_VAR = "_to_field" IS_FACETS_VAR = "_facets" @@ -1342,6 +1344,7 @@ class ModelAdmin(BaseModelAdmin): "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, } ) @@ -1398,12 +1401,39 @@ class ModelAdmin(BaseModelAdmin): 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 @@ -1913,6 +1943,7 @@ class ModelAdmin(BaseModelAdmin): "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, diff --git a/django/contrib/admin/static/admin/js/SelectBox.js b/django/contrib/admin/static/admin/js/SelectBox.js index 3db4ec7fa6..17c182c53f 100644 --- a/django/contrib/admin/static/admin/js/SelectBox.js +++ b/django/contrib/admin/static/admin/js/SelectBox.js @@ -1,5 +1,6 @@ 'use strict'; { + const getOptionGroupName = (option) => option.parentElement.label; const SelectBox = { cache: {}, init: function(id) { @@ -7,7 +8,12 @@ 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) { @@ -15,12 +21,25 @@ 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; @@ -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 @@ -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); }, @@ -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; } diff --git a/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js b/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js index 1fc03c6232..f366f30f7e 100644 --- a/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js +++ b/django/contrib/admin/static/admin/js/admin/RelatedObjectLookups.js @@ -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; @@ -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) { @@ -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"]; diff --git a/django/contrib/admin/static/admin/js/popup_response.js b/django/contrib/admin/static/admin/js/popup_response.js index fecf0f4798..bdd93a6eb5 100644 --- a/django/contrib/admin/static/admin/js/popup_response.js +++ b/django/contrib/admin/static/admin/js/popup_response.js @@ -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; } } diff --git a/django/contrib/admin/templates/admin/change_form.html b/django/contrib/admin/templates/admin/change_form.html index f6edffb4d4..7116f1b8b8 100644 --- a/django/contrib/admin/templates/admin/change_form.html +++ b/django/contrib/admin/templates/admin/change_form.html @@ -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"> diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py index 40a6b3bf3a..cd40f14ce3 100644 --- a/django/contrib/admin/views/main.py +++ b/django/contrib/admin/views/main.py @@ -11,6 +11,7 @@ from django.contrib.admin.exceptions import ( from django.contrib.admin.options import ( IS_FACETS_VAR, IS_POPUP_VAR, + SOURCE_MODEL_VAR, TO_FIELD_VAR, IncorrectLookupParameters, ShowFacets, @@ -49,6 +50,7 @@ IGNORED_PARAMS = ( SEARCH_VAR, IS_FACETS_VAR, IS_POPUP_VAR, + SOURCE_MODEL_VAR, TO_FIELD_VAR, ) diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py index f5c3939012..67eac083e7 100644 --- a/django/contrib/admin/widgets.py +++ b/django/contrib/admin/widgets.py @@ -333,16 +333,24 @@ class RelatedFieldWidgetWrapper(forms.Widget): ) 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 = { |
