From 29259b02025c58b8027cb306b2028f58112879ce Mon Sep 17 00:00:00 2001 From: Kees Hink Date: Fri, 2 Jun 2023 13:45:11 +0200 Subject: [PATCH 1/2] Allowed setting HTML attributes on Select widget options - Added tests for the Select widget, applied patch supplied by nessita - Set option_attrs on AutocompleteMixin so create_option doesn't need to check if the instance has an option_attrs attribute --- django/contrib/admin/widgets.py | 1 + django/forms/widgets.py | 14 +++++-- docs/ref/forms/widgets.txt | 12 ++++++ .../widget_tests/test_radioselect.py | 32 +++++++++++++++ tests/forms_tests/widget_tests/test_select.py | 41 +++++++++++++++++++ 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/django/contrib/admin/widgets.py b/django/contrib/admin/widgets.py index 5e3416bc28f4..e2513c4e9a19 100644 --- a/django/contrib/admin/widgets.py +++ b/django/contrib/admin/widgets.py @@ -490,6 +490,7 @@ def __init__(self, field, admin_site, attrs=None, choices=(), using=None): self.db = using self.choices = choices self.attrs = {} if attrs is None else attrs.copy() + self.option_attrs = {} self.i18n_name = get_select2_language() def get_url(self): diff --git a/django/forms/widgets.py b/django/forms/widgets.py index ab7c0f755f05..b40f9e347163 100644 --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -618,12 +618,13 @@ class ChoiceWidget(Widget): checked_attribute = {"checked": True} option_inherits_attrs = True - def __init__(self, attrs=None, choices=()): + def __init__(self, attrs=None, choices=(), option_attrs=None): super().__init__(attrs) # choices can be any iterable, but we may need to render this widget # multiple times. Thus, collapse it into a list so it can be consumed # more than once. self.choices = list(choices) + self.option_attrs = {} if option_attrs is None else option_attrs.copy() def __deepcopy__(self, memo): obj = copy.copy(self) @@ -689,9 +690,14 @@ def create_option( self, name, value, label, selected, index, subindex=None, attrs=None ): index = str(index) if subindex is None else "%s_%s" % (index, subindex) - option_attrs = ( - self.build_attrs(self.attrs, attrs) if self.option_inherits_attrs else {} - ) + + if self.option_attrs: + option_attrs = self.build_attrs(self.option_attrs) + elif self.option_inherits_attrs: + option_attrs = self.build_attrs(self.attrs, attrs) + else: + option_attrs = {} + if selected: option_attrs.update(self.checked_attribute) if "id" in option_attrs: diff --git a/docs/ref/forms/widgets.txt b/docs/ref/forms/widgets.txt index efff81ddbc2d..69b5540835b0 100644 --- a/docs/ref/forms/widgets.txt +++ b/docs/ref/forms/widgets.txt @@ -195,6 +195,18 @@ Django will then include the extra attributes in the rendered output: You can also set the HTML ``id`` using :attr:`~Widget.attrs`. See :attr:`BoundField.id_for_label` for an example. +For widgets that have options on their own, like those inheriting from :class:`ChoiceWidget`, +you can also pass option-specific attributes using using `option_attrs`. +For example, if you wish to set custom CSS classes for a RadioSelect's options, you could +do as follows: + +.. code-block:: pycon + + >>> from django.forms.widgets import RadioSelect + >>> widget = RadioSelect(choices=(("J", "John"),), option_attrs={"class": "special"}) + >>> widget.render(name="beatle", value=["J"]) + '
\n \n\n
\n
' + .. _styling-widget-classes: Styling widget classes diff --git a/tests/forms_tests/widget_tests/test_radioselect.py b/tests/forms_tests/widget_tests/test_radioselect.py index dc3f3d9bad70..d47f1b6bdba5 100644 --- a/tests/forms_tests/widget_tests/test_radioselect.py +++ b/tests/forms_tests/widget_tests/test_radioselect.py @@ -200,6 +200,38 @@ def test_render_as_subwidget(self): """, ) + def test_render_as_subwidget_with_option_attrs(self): + """We render option_attrs for the subwidget.""" + choices = (("", "------"),) + self.beatles + widget_instance = self.widget( + choices=choices, + option_attrs={"class": "special"}, + ) + self.check_html( + MultiWidget([widget_instance]), + "beatle", + ["J"], + html=""" +
+
+
+
+
+
+
+ """, + ) + def test_fieldset(self): class TestForm(Form): template_name = "forms_tests/use_fieldset.html" diff --git a/tests/forms_tests/widget_tests/test_select.py b/tests/forms_tests/widget_tests/test_select.py index 77450e3716b4..97458dc17729 100644 --- a/tests/forms_tests/widget_tests/test_select.py +++ b/tests/forms_tests/widget_tests/test_select.py @@ -111,6 +111,28 @@ def test_constructor_attrs(self): ), ) + def test_constructor_option_attrs(self): + """ + Select options shouldn't inherit the parent widget attrs. + """ + widget = Select( + attrs={"class": "super", "id": "super"}, + option_attrs={"data-test": "custom", "class": "other"}, + choices=[(1, 1), (2, 2), (3, 3)], + ) + self.check_html( + widget, + "num", + 2, + html=( + """""" + ), + ) + def test_compare_to_str(self): """ The value is compared to its str(). @@ -332,14 +354,33 @@ def test_options(self): self.assertEqual(options[0]["value"], "J") self.assertEqual(options[0]["label"], "John") self.assertEqual(options[0]["index"], "0") + self.assertEqual(options[0]["attrs"], {"selected": True}) self.assertIs(options[0]["selected"], True) # Template-related attributes self.assertEqual(options[1]["name"], "name") self.assertEqual(options[1]["value"], "P") self.assertEqual(options[1]["label"], "Paul") self.assertEqual(options[1]["index"], "1") + self.assertEqual(options[1]["attrs"], {}) self.assertIs(options[1]["selected"], False) + def test_options_with_option_attrs(self): + options = list( + self.widget(choices=self.beatles, option_attrs={"class": "other"}).options( + "name", + ["J"], + attrs={"class": "super"}, + ) + ) + self.assertEqual(len(options), 4) + for option, (i, (value, label)) in zip(options, enumerate(self.beatles)): + self.assertEqual(option["name"], "name") + self.assertEqual(option["value"], value) + self.assertEqual(option["label"], label) + self.assertEqual(option["index"], str(i)) + self.assertEqual(option["attrs"]["class"], "other") + self.assertIs(option["selected"], value == "J") + def test_optgroups(self): choices = [ ( From 8a2abd3351832724519693631e02679e5984a9e2 Mon Sep 17 00:00:00 2001 From: Mariana Date: Sun, 5 May 2024 16:15:37 +0100 Subject: [PATCH 2/2] Fixed #34034 -- Allow setting HTML attributes on Select widget options. --- django/forms/widgets.py | 1 + docs/ref/forms/widgets.txt | 4 +-- .../widget_tests/test_choicewidget.py | 2 ++ .../widget_tests/test_radioselect.py | 36 +++++++++++++++++++ tests/forms_tests/widget_tests/test_select.py | 17 +++++++++ 5 files changed, 58 insertions(+), 2 deletions(-) diff --git a/django/forms/widgets.py b/django/forms/widgets.py index 9efc8d8c7643..623801eb668e 100644 --- a/django/forms/widgets.py +++ b/django/forms/widgets.py @@ -631,6 +631,7 @@ def __init__(self, attrs=None, choices=(), option_attrs=None): def __deepcopy__(self, memo): obj = copy.copy(self) obj.attrs = self.attrs.copy() + obj.option_attrs = self.option_attrs.copy() obj.choices = copy.copy(self.choices) memo[id(self)] = obj return obj diff --git a/docs/ref/forms/widgets.txt b/docs/ref/forms/widgets.txt index e4da8d800ec4..8f52b7e09597 100644 --- a/docs/ref/forms/widgets.txt +++ b/docs/ref/forms/widgets.txt @@ -195,8 +195,8 @@ Django will then include the extra attributes in the rendered output: You can also set the HTML ``id`` using :attr:`~Widget.attrs`. See :attr:`BoundField.id_for_label` for an example. -For widgets that have options on their own, like those inheriting from :class:`ChoiceWidget`, -you can also pass option-specific attributes using using `option_attrs`. +For widgets that have options on their own, like those inheriting from ``ChoiceWidget``, +you can also pass option-specific attributes using ``option_attrs``. For example, if you wish to set custom CSS classes for a RadioSelect's options, you could do as follows: diff --git a/tests/forms_tests/widget_tests/test_choicewidget.py b/tests/forms_tests/widget_tests/test_choicewidget.py index abd1961b3210..f7645c4eb9fa 100644 --- a/tests/forms_tests/widget_tests/test_choicewidget.py +++ b/tests/forms_tests/widget_tests/test_choicewidget.py @@ -41,6 +41,8 @@ def test_deepcopy(self): self.assertIsNot(widget.choices, obj.choices) self.assertEqual(widget.attrs, obj.attrs) self.assertIsNot(widget.attrs, obj.attrs) + self.assertEqual(widget.option_attrs, obj.option_attrs) + self.assertIsNot(widget.option_attrs, obj.option_attrs) def test_options(self): options = list( diff --git a/tests/forms_tests/widget_tests/test_radioselect.py b/tests/forms_tests/widget_tests/test_radioselect.py index 566593c7c752..c9a6ed79a1cc 100644 --- a/tests/forms_tests/widget_tests/test_radioselect.py +++ b/tests/forms_tests/widget_tests/test_radioselect.py @@ -179,6 +179,42 @@ def test_constructor_attrs(self): """ self.check_html(widget, "beatle", "J", html=html) + def test_constructor_option_attrs(self): + """ + Attributes provided at instantiation are passed to the constituent + inputs. + """ + widget = self.widget( + attrs={"id": "foo"}, + option_attrs={"data-test": "custom", "class": "other"}, + choices=self.beatles, + ) + html = """ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ """ + self.check_html(widget, "beatle", "J", html=html) + def test_compare_to_str(self): """ The value is compared to its str(). diff --git a/tests/forms_tests/widget_tests/test_select.py b/tests/forms_tests/widget_tests/test_select.py index fd17286a8c3f..9c88a4a1294e 100644 --- a/tests/forms_tests/widget_tests/test_select.py +++ b/tests/forms_tests/widget_tests/test_select.py @@ -326,6 +326,23 @@ def test_doesnt_localize_option_value(self): """ self.check_html(self.widget(choices=choices), "time", None, html=html) + def test_options_with_option_attrs(self): + options = list( + self.widget(choices=self.beatles, option_attrs={"class": "other"}).options( + "name", + ["J"], + attrs={"class": "super"}, + ) + ) + self.assertEqual(len(options), 4) + for option, (i, (value, label)) in zip(options, enumerate(self.beatles)): + self.assertEqual(option["name"], "name") + self.assertEqual(option["value"], value) + self.assertEqual(option["label"], label) + self.assertEqual(option["index"], str(i)) + self.assertEqual(option["attrs"]["class"], "other") + self.assertIs(option["selected"], value == "J") + def _test_optgroups(self, choices): groups = list( self.widget(choices=choices).optgroups(