Skip to content

Commit 2ccf8d1

Browse files
committed
[feature] Add Select2Widget for choice fields #254
Implement a reusable Select2Widget that uses Django's native admin assets to avoid extra dependencies. Closes #254
1 parent 98540a6 commit 2ccf8d1

5 files changed

Lines changed: 114 additions & 1 deletion

File tree

openwisp_utils/admin.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.core.exceptions import FieldError
33
from django.urls import reverse
44
from django.utils.translation import gettext_lazy as _
5+
from openwisp_utils.widgets import Select2Widget
56

67

78
class TimeReadonlyAdminMixin(object):
@@ -217,3 +218,14 @@ def get_formset(self, request, obj=None, **kwargs):
217218
formset = super().get_formset(request, obj, **kwargs)
218219
formset.help_text = self.help_text
219220
return formset
221+
222+
223+
class Select2AdminMixin:
224+
"""Mixin that applies Select2Widget to specified choice fields."""
225+
226+
select2_fields = ()
227+
228+
def formfield_for_choice_field(self, db_field, request, **kwargs):
229+
if db_field.name in self.select2_fields:
230+
kwargs["widget"] = Select2Widget()
231+
return super().formfield_for_choice_field(db_field, request, **kwargs)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"use strict";
2+
3+
django.jQuery(function ($) {
4+
function initSelect2($element) {
5+
$element.not('select[name*="__prefix__"]').each(function () {
6+
var $el = $(this);
7+
if (!$el.hasClass("select2-hidden-accessible")) {
8+
$el.select2();
9+
}
10+
});
11+
}
12+
13+
initSelect2($("select.ow-select2"));
14+
15+
$(document).on("formset:added", function (event, $row) {
16+
initSelect2($row.find("select.ow-select2"));
17+
});
18+
});

openwisp_utils/widgets.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from django.conf import settings
2+
from django.contrib.admin.widgets import SELECT2_TRANSLATIONS
3+
from django.forms import Media, Select
4+
from django.utils.translation import get_language
5+
6+
7+
class Select2Widget(Select):
8+
"""Select2 autocomplete widget for Django ChoiceFields."""
9+
10+
@property
11+
def media(self):
12+
extra = "" if getattr(settings, "DEBUG", False) else ".min"
13+
i18n_name = SELECT2_TRANSLATIONS.get(get_language())
14+
i18n_file = (
15+
("admin/js/vendor/select2/i18n/{0}.js".format(i18n_name),)
16+
if i18n_name
17+
else ()
18+
)
19+
return Media(
20+
js=(
21+
"admin/js/vendor/jquery/jquery{0}.js".format(extra),
22+
"admin/js/vendor/select2/select2.full{0}.js".format(extra),
23+
)
24+
+ i18n_file
25+
+ ("admin/js/jquery.init.js", "openwisp-utils/js/select2.js"),
26+
css={
27+
"screen": ("admin/css/vendor/select2/select2{0}.css".format(extra),),
28+
},
29+
)
30+
31+
def __init__(self, attrs=None, choices=()):
32+
attrs = attrs or {}
33+
attrs["class"] = "ow-select2 {0}".format(attrs.get("class", "")).strip()
34+
super().__init__(attrs, choices)

tests/test_project/admin.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
HelpTextStackedInline,
88
ReadOnlyAdmin,
99
ReceiveUrlAdmin,
10+
Select2AdminMixin,
1011
TimeReadonlyAdminMixin,
1112
UUIDAdmin,
1213
)
@@ -110,7 +111,7 @@ class AutoOwnerFilter(AutocompleteFilter):
110111

111112

112113
@admin.register(Shelf)
113-
class ShelfAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin):
114+
class ShelfAdmin(Select2AdminMixin, TimeReadonlyAdminMixin, admin.ModelAdmin):
114115
# DO NOT CHANGE: used for testing filters
115116
list_filter = [
116117
ShelfFilter,
@@ -121,6 +122,7 @@ class ShelfAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin):
121122
ReverseBookFilter,
122123
]
123124
search_fields = ["name"]
125+
select2_fields = ("books_type",)
124126

125127

126128
@admin.register(OrganizationRadiusSettings)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from channels.testing import ChannelsLiveServerTestCase
2+
from django.test import TestCase, tag
3+
from django.urls import reverse
4+
from openwisp_utils.widgets import Select2Widget
5+
from selenium.webdriver.common.by import By
6+
7+
from ..models import Shelf
8+
from .utils import SeleniumTestMixin
9+
10+
11+
class TestWidgets(TestCase):
12+
def test_select2_widget_attrs(self):
13+
widget = Select2Widget()
14+
html = widget.render("name", "value")
15+
self.assertIn('class="ow-select2"', html)
16+
17+
# test overriding works and class is preserved
18+
widget = Select2Widget(attrs={"class": "my-class"})
19+
html = widget.render("name", "value")
20+
self.assertIn("ow-select2 my-class", html)
21+
22+
def test_select2_widget_media(self):
23+
widget = Select2Widget()
24+
media = str(widget.media)
25+
self.assertIn("admin/css/vendor/select2/select2", media)
26+
self.assertIn("admin/js/vendor/jquery/jquery", media)
27+
self.assertIn("admin/js/vendor/select2/select2.full", media)
28+
self.assertIn("openwisp-utils/js/select2.js", media)
29+
30+
31+
@tag("selenium_tests")
32+
class TestSelect2AdminMixinSelenium(SeleniumTestMixin, ChannelsLiveServerTestCase):
33+
def setUp(self):
34+
super().setUp()
35+
self.login()
36+
37+
def test_select2_widget_renders_on_shelf_add_form(self):
38+
url = reverse("admin:test_project_shelf_add")
39+
self.open(url)
40+
self.wait_for_presence(By.CSS_SELECTOR, "select#id_books_type.ow-select2")
41+
42+
def test_select2_widget_renders_on_shelf_change_form(self):
43+
shelf = Shelf.objects.create(name="Test Shelf", books_type="HORROR")
44+
url = reverse("admin:test_project_shelf_change", args=[shelf.pk])
45+
self.open(url)
46+
self.wait_for_presence(By.CSS_SELECTOR, "select#id_books_type.ow-select2")
47+
self.wait_for_presence(By.CSS_SELECTOR, ".select2-container")

0 commit comments

Comments
 (0)