diff --git a/openwisp_utils/admin.py b/openwisp_utils/admin.py index bfaba7e2..face1373 100644 --- a/openwisp_utils/admin.py +++ b/openwisp_utils/admin.py @@ -2,6 +2,7 @@ from django.core.exceptions import FieldError from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from openwisp_utils.widgets import Select2Widget class TimeReadonlyAdminMixin(object): @@ -217,3 +218,14 @@ def get_formset(self, request, obj=None, **kwargs): formset = super().get_formset(request, obj, **kwargs) formset.help_text = self.help_text return formset + + +class Select2AdminMixin: + """Mixin that applies Select2Widget to specified choice fields.""" + + select2_fields = () + + def formfield_for_choice_field(self, db_field, request, **kwargs): + if db_field.name in self.select2_fields: + kwargs["widget"] = Select2Widget() + return super().formfield_for_choice_field(db_field, request, **kwargs) diff --git a/openwisp_utils/static/openwisp-utils/js/select2.js b/openwisp_utils/static/openwisp-utils/js/select2.js new file mode 100644 index 00000000..308d284a --- /dev/null +++ b/openwisp_utils/static/openwisp-utils/js/select2.js @@ -0,0 +1,18 @@ +"use strict"; + +django.jQuery(function ($) { + function initSelect2($element) { + $element.not('select[name*="__prefix__"]').each(function () { + var $el = $(this); + if (!$el.hasClass("select2-hidden-accessible")) { + $el.select2(); + } + }); + } + + initSelect2($("select.ow-select2")); + + $(document).on("formset:added", function (event, $row) { + initSelect2($row.find("select.ow-select2")); + }); +}); diff --git a/openwisp_utils/widgets.py b/openwisp_utils/widgets.py new file mode 100644 index 00000000..a0257860 --- /dev/null +++ b/openwisp_utils/widgets.py @@ -0,0 +1,34 @@ +from django.conf import settings +from django.contrib.admin.widgets import SELECT2_TRANSLATIONS +from django.forms import Media, Select +from django.utils.translation import get_language + + +class Select2Widget(Select): + """Select2 autocomplete widget for Django ChoiceFields.""" + + @property + def media(self): + extra = "" if getattr(settings, "DEBUG", False) else ".min" + i18n_name = SELECT2_TRANSLATIONS.get(get_language()) + i18n_file = ( + ("admin/js/vendor/select2/i18n/{0}.js".format(i18n_name),) + if i18n_name + else () + ) + return Media( + js=( + "admin/js/vendor/jquery/jquery{0}.js".format(extra), + "admin/js/vendor/select2/select2.full{0}.js".format(extra), + ) + + i18n_file + + ("admin/js/jquery.init.js", "openwisp-utils/js/select2.js"), + css={ + "screen": ("admin/css/vendor/select2/select2{0}.css".format(extra),), + }, + ) + + def __init__(self, attrs=None, choices=()): + attrs = attrs or {} + attrs["class"] = "ow-select2 {0}".format(attrs.get("class", "")).strip() + super().__init__(attrs, choices) diff --git a/tests/test_project/admin.py b/tests/test_project/admin.py index 3ea8fd49..7fc2ef19 100644 --- a/tests/test_project/admin.py +++ b/tests/test_project/admin.py @@ -7,6 +7,7 @@ HelpTextStackedInline, ReadOnlyAdmin, ReceiveUrlAdmin, + Select2AdminMixin, TimeReadonlyAdminMixin, UUIDAdmin, ) @@ -110,7 +111,7 @@ class AutoOwnerFilter(AutocompleteFilter): @admin.register(Shelf) -class ShelfAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin): +class ShelfAdmin(Select2AdminMixin, TimeReadonlyAdminMixin, admin.ModelAdmin): # DO NOT CHANGE: used for testing filters list_filter = [ ShelfFilter, @@ -121,6 +122,7 @@ class ShelfAdmin(TimeReadonlyAdminMixin, admin.ModelAdmin): ReverseBookFilter, ] search_fields = ["name"] + select2_fields = ("books_type",) @admin.register(OrganizationRadiusSettings) diff --git a/tests/test_project/tests/test_widgets.py b/tests/test_project/tests/test_widgets.py new file mode 100644 index 00000000..e717b2b8 --- /dev/null +++ b/tests/test_project/tests/test_widgets.py @@ -0,0 +1,47 @@ +from channels.testing import ChannelsLiveServerTestCase +from django.test import TestCase, tag +from django.urls import reverse +from openwisp_utils.widgets import Select2Widget +from selenium.webdriver.common.by import By + +from ..models import Shelf +from .utils import SeleniumTestMixin + + +class TestWidgets(TestCase): + def test_select2_widget_attrs(self): + widget = Select2Widget() + html = widget.render("name", "value") + self.assertIn('class="ow-select2"', html) + + # test overriding works and class is preserved + widget = Select2Widget(attrs={"class": "my-class"}) + html = widget.render("name", "value") + self.assertIn("ow-select2 my-class", html) + + def test_select2_widget_media(self): + widget = Select2Widget() + media = str(widget.media) + self.assertIn("admin/css/vendor/select2/select2", media) + self.assertIn("admin/js/vendor/jquery/jquery", media) + self.assertIn("admin/js/vendor/select2/select2.full", media) + self.assertIn("openwisp-utils/js/select2.js", media) + + +@tag("selenium_tests") +class TestSelect2AdminMixinSelenium(SeleniumTestMixin, ChannelsLiveServerTestCase): + def setUp(self): + super().setUp() + self.login() + + def test_select2_widget_renders_on_shelf_add_form(self): + url = reverse("admin:test_project_shelf_add") + self.open(url) + self.wait_for_presence(By.CSS_SELECTOR, "select#id_books_type.ow-select2") + + def test_select2_widget_renders_on_shelf_change_form(self): + shelf = Shelf.objects.create(name="Test Shelf", books_type="HORROR") + url = reverse("admin:test_project_shelf_change", args=[shelf.pk]) + self.open(url) + self.wait_for_presence(By.CSS_SELECTOR, "select#id_books_type.ow-select2") + self.wait_for_presence(By.CSS_SELECTOR, ".select2-container")