diff --git a/django/contrib/admin/static/admin/js/filters.js b/django/contrib/admin/static/admin/js/filters.js index ba691ac82f..f5536ebc2d 100644 --- a/django/contrib/admin/static/admin/js/filters.js +++ b/django/contrib/admin/static/admin/js/filters.js @@ -11,7 +11,7 @@ } Object.entries(filters).forEach(([key, value]) => { - const detailElement = document.querySelector(`[data-filter-title='${key}']`); + const detailElement = document.querySelector(`[data-filter-title='${CSS.escape(key)}']`); // Check if the filter is present, it could be from other view. if (detailElement) { diff --git a/tests/admin_changelist/admin.py b/tests/admin_changelist/admin.py index 67187f5b79..8ffc45e391 100644 --- a/tests/admin_changelist/admin.py +++ b/tests/admin_changelist/admin.py @@ -3,7 +3,7 @@ from django.contrib.auth.admin import UserAdmin from django.contrib.auth.models import User from django.core.paginator import Paginator -from .models import Band, Child, Event, Parent, Swallow +from .models import Band, Child, Event, Parent, ProxyUser, Swallow site = admin.AdminSite(name="admin") @@ -185,3 +185,24 @@ class EmptyValueChildAdmin(admin.ModelAdmin): @admin.display(empty_value="†") def age_display(self, obj): return obj.age + + +class UnescapedTitleFilter(admin.SimpleListFilter): + title = "It's OK" + parameter_name = "is_active" + + def lookups(self, request, model_admin): + return [("yes", "yes"), ("no", "no")] + + def queryset(self, request, queryset): + if self.value() == "yes": + return queryset.filter(is_active=True) + else: + return queryset.filter(is_active=False) + + +class CustomUserAdmin(UserAdmin): + list_filter = [UnescapedTitleFilter] + + +site.register(ProxyUser, CustomUserAdmin) diff --git a/tests/admin_changelist/models.py b/tests/admin_changelist/models.py index 180c38428a..aa4656e93e 100644 --- a/tests/admin_changelist/models.py +++ b/tests/admin_changelist/models.py @@ -1,5 +1,6 @@ import uuid +from django.contrib.auth.models import User from django.db import models @@ -121,3 +122,8 @@ class CustomIdUser(models.Model): class CharPK(models.Model): char_pk = models.CharField(max_length=100, primary_key=True) + + +class ProxyUser(User): + class Meta: + proxy = True diff --git a/tests/admin_changelist/tests.py b/tests/admin_changelist/tests.py index 128fbb6aac..bab988af23 100644 --- a/tests/admin_changelist/tests.py +++ b/tests/admin_changelist/tests.py @@ -1910,3 +1910,23 @@ class SeleniumTests(AdminSeleniumTestCase): "[data-filter-title='number of members']", ).get_attribute("open") ) + + def test_collapse_filter_with_unescaped_title(self): + from selenium.webdriver.common.by import By + + self.admin_login(username="super", password="secret") + changelist_url = reverse("admin:admin_changelist_proxyuser_changelist") + self.selenium.get(self.live_server_url + changelist_url) + # Title is escaped. + filter_title = self.selenium.find_element( + By.CSS_SELECTOR, "[data-filter-title='It\\'s OK']" + ) + filter_title.find_element(By.CSS_SELECTOR, "summary").click() + self.assertFalse(filter_title.get_attribute("open")) + # Filter is in the same state after refresh. + self.selenium.refresh() + self.assertFalse( + self.selenium.find_element( + By.CSS_SELECTOR, "[data-filter-title='It\\'s OK']" + ).get_attribute("open") + )