From 55bf36f0ff0836f7bad3007be39f29568e16d0be Mon Sep 17 00:00:00 2001 From: Meir Date: Thu, 11 May 2023 02:25:25 +0200 Subject: [PATCH 1/4] =?UTF-8?q?preparas=20formularan=20kampon=20kaj=20kore?= =?UTF-8?q?spondajn=20widgets=20por=20kondi=C4=89oj=20en=20la=20ser=C4=89o?= =?UTF-8?q?-formularo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hosting/fields.py | 42 +++- .../templates/ui/widget-tristate_select.html | 10 + hosting/widgets.py | 64 ++++++ tests/forms/test_fields.py | 184 ++++++++++++++++++ tests/forms/test_widgets.py | 100 +++++++++- 5 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 hosting/templates/ui/widget-tristate_select.html create mode 100644 tests/forms/test_fields.py diff --git a/hosting/fields.py b/hosting/fields.py index 68177c37..63a0b310 100644 --- a/hosting/fields.py +++ b/hosting/fields.py @@ -13,7 +13,7 @@ PhoneNumberField as DjangoPhoneNumberField, ) -from .widgets import TextWithDatalistInput +from .widgets import MultiNullBooleanSelects, TextWithDatalistInput class StyledEmailField(models.EmailField): @@ -226,3 +226,43 @@ def to_python(self, value): defaults = {'form_class': SuggestiveModelChoiceFormField} defaults.update(kwargs) return super().formfield(**defaults) + + +class MultiNullBooleanFormField(forms.MultiValueField): + widget = MultiNullBooleanSelects + + def __init__( + self, base_field, boolean_choices, *args, + label_prefix=lambda choice: None, **kwargs, + ): + self.choices = list(base_field.choices) + if isinstance(base_field.choices, forms.models.ModelChoiceIterator): + self._single_choice_value = lambda choice: choice.value + else: + self._single_choice_value = lambda choice: choice + field_labels = { + self._single_choice_value(choice_value): + (choice_label, label_prefix(choice_value)) + for choice_value, choice_label in self.choices + } + fields = [ + forms.NullBooleanField(label=choice_label, required=False) + for _, choice_label in self.choices + ] + super().__init__( + fields, + *args, + required=base_field.required, + require_all_fields=False, + widget=self.widget(field_labels, boolean_choices), + **kwargs) + self.empty_values = list(v for v in self.empty_values if v is not None) + + def compress(self, data_list): + if not data_list: + data_list = [None] * len(self.choices) + # Each value of the data_list corresponds to a field. + return zip( + [self._single_choice_value(choice_value) for choice_value, _ in self.choices], + data_list + ) diff --git a/hosting/templates/ui/widget-tristate_select.html b/hosting/templates/ui/widget-tristate_select.html new file mode 100644 index 00000000..b7e93c9f --- /dev/null +++ b/hosting/templates/ui/widget-tristate_select.html @@ -0,0 +1,10 @@ +
+
+ + {% if widget.label_prefix %}{{ widget.label_prefix }}: {% endif %}{{ widget.label }} + +
+
+ {% include 'django/forms/widgets/select.html' with widget=widget only %} +
+
diff --git a/hosting/widgets.py b/hosting/widgets.py index 50806eec..854b9dd3 100644 --- a/hosting/widgets.py +++ b/hosting/widgets.py @@ -1,3 +1,5 @@ +import re + from django.forms import widgets as form_widgets from django.template.loader import get_template from django.utils.safestring import mark_safe @@ -41,6 +43,68 @@ def get_context(self, name, value, attrs): return context +class CustomNullBooleanSelect(form_widgets.NullBooleanSelect): + template_name = 'ui/widget-tristate_select.html' + + def __init__(self, label, choices, label_prefix=None, attrs=None): + """ + @param `choices` must be an iterable of 2-tuples, where the 1st element of + the tuple should be 'unknown', 'true', or 'false', with all three present. + """ + super().__init__(attrs) + self.choices = list(choices) + self.label = label + self.label_prefix = label_prefix + + def get_context(self, name, value, attrs): + context = super().get_context(name, value, attrs) + context['widget']['label'] = self.label + context['widget']['label_prefix'] = self.label_prefix + css_class = context['widget']['attrs'].get('class', '') + if "form-control" not in css_class: + context['widget']['attrs']['class'] = "form-control " + css_class + context['widget']['attrs']['aria-labelledby'] = ( + context['widget']['attrs']['id'] + '_label' + ) + return context + + +class MultiNullBooleanSelects(form_widgets.MultiWidget): + def __init__(self, labels, choices_per_label, attrs=None): + """ + @param `labels` is either an iterable or dict of 2-tuples, where the 1st + element is the label of the enclosed widget and the 2nd element is the + label's prefix (can be None). Dict keys are used as the name suffixes of + the enclosed widgets. + + @param `choices_per_label` is an iterable of 2-tuples, where the 1st + element should be 'unknown', 'true', or 'false', with all three present. + """ + if isinstance(labels, dict): + widgets = { + name: CustomNullBooleanSelect(label, choices_per_label, prefix) + for name, (label, prefix) in labels.items() + } + else: + widgets = [ + CustomNullBooleanSelect(label, choices_per_label, prefix) + for label, prefix in labels + ] + super().__init__(widgets, attrs) + + def id_for_label(self, id_): + return id_ + + def decompress(self, value): + # Typically used when value is not already a simple list of True-False-None. + return [ + selected_value for id, selected_value in ( + value if isinstance(value, list) + else map(lambda v: (None, v), re.split(r',\s*', str(value).strip())) + ) + ] + + class InlineRadios(CrispyField): """ Form Layout object for rendering radio buttons inline. diff --git a/tests/forms/test_fields.py b/tests/forms/test_fields.py new file mode 100644 index 00000000..8da6507a --- /dev/null +++ b/tests/forms/test_fields.py @@ -0,0 +1,184 @@ +from django import forms +from django.db import models +from django.test import TestCase, modify_settings, tag + +from hosting.fields import MultiNullBooleanFormField +from hosting.models import Condition +from hosting.widgets import MultiNullBooleanSelects + +from ..assertions import AdditionalAsserts +from ..factories import ConditionFactory + + +@tag('forms', 'form-fields') +class MultiNullBooleanFieldTests(AdditionalAsserts, TestCase): + ROUND_BAKED_GOODS_OFFER = [(10, "Veggie"), (20, "Funghi"), (30, "Margarita"), (40, "Caprese")] + NULL_BOOLEAN_CHOICES = [('true', "yes please"), ('false', "no way"), ('unknown', "whatever")] + + def test_init(self): + field = MultiNullBooleanFormField( + forms.ChoiceField(choices=self.ROUND_BAKED_GOODS_OFFER), + self.NULL_BOOLEAN_CHOICES) + # The number of subfields is expected to be equal to the number of choices. + self.assertEqual(len(field.fields), 4) + # Each subfield is expected to be a NullBooleanField. + for f in field.fields: + self.assertIsInstance(f, forms.NullBooleanField) + self.assertFalse(f.required) + self.assertTrue(field.required) + # None is expected to not be included in `empty_values`, because it is + # a valid value. + self.assertNotIn(None, field.empty_values) + + def test_validation(self): + field = MultiNullBooleanFormField( + forms.ChoiceField(choices=self.ROUND_BAKED_GOODS_OFFER), + self.NULL_BOOLEAN_CHOICES) + + # Empty values are expected to fail the validation, because by default + # the field has required=True. + with self.assertRaises(forms.ValidationError) as cm: + field.clean([]) + self.assertEqual(getattr(cm.exception, 'code', None), 'required') + with self.assertRaises(forms.ValidationError) as cm: + field.clean('') + self.assertEqual(getattr(cm.exception, 'code', None), 'required') + + # Non-empty values are expected to pass the validation, with anything + # not recognised as True or False treated as unknown, thus None. + with self.assertNotRaises(forms.ValidationError): + cleaned_value = field.clean(['x', 'true']) + self.assertEqual( + list(cleaned_value), + [(10, None), (20, True), (30, None), (40, None)] + ) + # A non-empty value which is not a list is expected to be invalid. + with self.assertRaises(forms.ValidationError) as cm: + field.clean(' z,y,false ') + self.assertEqual(getattr(cm.exception, 'code', None), 'invalid') + + field = MultiNullBooleanFormField( + forms.ChoiceField(choices=self.ROUND_BAKED_GOODS_OFFER, required=False), + self.NULL_BOOLEAN_CHOICES) + + # Empty values for a not required field are expected to be valid. + with self.assertNotRaises(forms.ValidationError): + cleaned_value = field.clean([]) + self.assertEqual( + list(cleaned_value), + [(10, None), (20, None), (30, None), (40, None)] + ) + with self.assertNotRaises(forms.ValidationError): + cleaned_value = field.clean('') + self.assertEqual( + list(cleaned_value), + [(10, None), (20, None), (30, None), (40, None)] + ) + + def test_validation_on_disabled_field(self): + field = MultiNullBooleanFormField( + forms.ChoiceField(choices=self.ROUND_BAKED_GOODS_OFFER, required=False), + self.NULL_BOOLEAN_CHOICES, disabled=True) + + with self.assertNotRaises(forms.ValidationError): + cleaned_value = field.clean([None, 0, False]) + self.assertEqual( + list(cleaned_value), + [(10, None), (20, False), (30, False), (40, None)] + ) + with self.assertNotRaises(forms.ValidationError): + cleaned_value = field.clean(' z,y,false ') + self.assertEqual( + list(cleaned_value), + [(10, None), (20, None), (30, False), (40, None)] + ) + + with self.assertNotRaises(forms.ValidationError): + cleaned_value = field.clean([]) + self.assertEqual( + list(cleaned_value), + [(10, None), (20, None), (30, None), (40, None)] + ) + with self.assertNotRaises(forms.ValidationError): + cleaned_value = field.clean('') + self.assertEqual( + list(cleaned_value), + [(10, None), (20, None), (30, None), (40, None)] + ) + + def widget_tests(self, form): + with self.subTest(form=form.__class__.__name__): + # The default form field widget is expected to be MultiNullBooleanSelects. + self.assertIsInstance(form.fields['extra_order'].widget, MultiNullBooleanSelects) + # The choices are expected to be split into separate widgets. + self.assertEqual(len(form.fields['extra_order'].widget.widgets), 4) + # Widget name suffixes are expected to be the choice values. + self.assertEqual( + form.fields['extra_order'].widget.widgets_names, + [f'_{value}' for value, label in form.fields['pizza_choices'].choices] + ) + # Each choice widget is expected to have the 3 null boolean options, + # in the order specified in the field's definition. + original_choices = list(form.fields['pizza_choices'].choices) + for i, w in enumerate(form.fields['extra_order'].widget.widgets): + self.assertEqual(w.label, original_choices[i][1]) + self.assertEqual([k for k, label in w.choices], ['true', 'false', 'unknown']) + + # The HTML element IDs of the choice widgets are expected to be numbered + # from 0 to number of choices, while their names are expected to have the + # choice value appended. + rendered_form = form.as_p() + for i in range(4): + self.assertIn(f'id="id_extra_order_{i}"', rendered_form) + self.assertIn(f'name="extra_order_{original_choices[i][0]}"', rendered_form) + + def test_widget_for_simple_form(self): + class TastyForm(forms.Form): + pizza_choices = forms.ChoiceField(choices=self.ROUND_BAKED_GOODS_OFFER) + extra_order = MultiNullBooleanFormField(pizza_choices, self.NULL_BOOLEAN_CHOICES) + + self.widget_tests(TastyForm()) + + @modify_settings(INSTALLED_APPS={ + 'append': 'tests.forms.test_fields', + }) + def test_widget_for_model_form(self): + class TastyModel(models.Model): + pizza_choices = models.CharField( + choices=self.ROUND_BAKED_GOODS_OFFER, blank=False, default=30) + + class TastyModelForm(forms.ModelForm): + class Meta: + model = TastyModel + fields = '__all__' + + def __init__(form_self, *args, **kwargs): + super().__init__(*args, **kwargs) + form_self.fields['extra_order'] = MultiNullBooleanFormField( + form_self.fields['pizza_choices'], self.NULL_BOOLEAN_CHOICES + ) + + self.widget_tests(TastyModelForm()) + + @modify_settings(INSTALLED_APPS={ + 'append': 'tests.forms.test_fields', + }) + def test_widget_for_foreign_key(self): + Condition.objects.all().delete() + ConditionFactory.create_batch(4) + + class MightBeTastyModel(models.Model): + pizza_choices = models.ManyToManyField(Condition, blank=False) + + class TastyByReferenceModelForm(forms.ModelForm): + class Meta: + model = MightBeTastyModel + fields = ['pizza_choices'] + + def __init__(form_self, *args, **kwargs): + super().__init__(*args, **kwargs) + form_self.fields['extra_order'] = MultiNullBooleanFormField( + form_self.fields['pizza_choices'], self.NULL_BOOLEAN_CHOICES + ) + + self.widget_tests(TastyByReferenceModelForm()) diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index 17c60597..fbd67092 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -11,7 +11,8 @@ from factory import Faker from hosting.widgets import ( - ClearableWithPreviewImageInput, InlineRadios, TextWithDatalistInput, + ClearableWithPreviewImageInput, CustomNullBooleanSelect, + InlineRadios, MultiNullBooleanSelects, TextWithDatalistInput, ) from maps.widgets import AdminMapboxGlWidget, MapboxGlWidget @@ -70,6 +71,103 @@ def test_render(self): self.assertRegex(result, ']*id="id_code_field_options"') +@tag('forms', 'widgets') +class CustomNullBooleanSelectWidgetTests(TestCase): + NULL_BOOLEAN_CHOICES = [('true', "hij"), ('false', "klm"), ('unknown', "nop")] + + def test_render(self): + widget = CustomNullBooleanSelect("Pick one", self.NULL_BOOLEAN_CHOICES) + + result = widget.render('null_bool_field', None, attrs={'id': 'id_bool_field'}) + self.assertInHTML( + 'Pick one', + result) + self.assertInHTML( + ''' + + ''', + result) + + result = widget.render('null_bool_field', True, attrs={'id': 'id_bool_field'}) + self.assertInHTML('', result) + self.assertInHTML('', result) + + result = widget.render('null_bool_field', False, attrs={'id': 'id_bool_field'}) + self.assertInHTML('', result) + self.assertInHTML('', result) + + def test_render_with_prefix(self): + widget = CustomNullBooleanSelect("Pick another", self.NULL_BOOLEAN_CHOICES, "Here") + result = widget.render('null_bool_field', "?", attrs={'id': 'id_bool_field'}) + self.assertInHTML( + 'Here: Pick another', + result) + self.assertInHTML('', result) + + def test_css_class(self): + widget = CustomNullBooleanSelect("Don't pick", self.NULL_BOOLEAN_CHOICES) + + result = widget.render( + 'null_bool_field', "X", attrs={'id': 'id_bool_field', 'class': "fancy"}) + self.assertRegex( + result, + r']*class="\s*(not-fancy\s*form-control|form-control\s*not-fancy)\s*"' + ) + + result = widget.render( + 'null_bool_field', "Z", + attrs={'id': 'id_bool_field', 'class': "first-level form-control required"}) + self.assertRegex( + result, + r' + {% endfilter %} + {{ option.label }} + + + {% endfor %} + {% endfor %} + + {% if forloop.last %} + {% include 'bootstrap3/layout/help_text_and_errors.html' %} + {% endif %} + + + {% endfor %} + {% endfor %} + diff --git a/tests/forms/test_search_forms.py b/tests/forms/test_search_forms.py index 69aa1a1f..7102d74d 100644 --- a/tests/forms/test_search_forms.py +++ b/tests/forms/test_search_forms.py @@ -23,8 +23,7 @@ def test_init(self): # Verify that the expected fields are part of the form. expected_fields = """ max_guest max_night contact_before tour_guide have_a_drink - owner__first_name owner__last_name available - facilitations restrictions + owner__first_name owner__last_name available conditions """.split() self.assertEqual(set(expected_fields), set(form.fields)) @@ -54,17 +53,11 @@ def test_labels_en(self): self.assertEqual(form.fields['have_a_drink'].label, "Yes") self.assertTrue(hasattr(form.fields['have_a_drink'], 'extra_label')) self.assertEqual(form.fields['have_a_drink'].extra_label, "Have a drink") + self.assertEqual(form.fields['conditions'].label, "Conditions") self.assertEqual(form.fields['owner__first_name'].label, "First name") self.assertEqual(form.fields['owner__last_name'].label, "Last name") - self.assertEqual( - form.fields['facilitations'].label, - "Show hosts with such facilitation") - self.assertEqual( - form.fields['restrictions'].label, - "Don't show hosts with such restriction") - @override_settings(LANGUAGE_CODE='eo') def test_labels_eo(self): # Workaround due to FilterSet evaluating labels too early. @@ -86,17 +79,11 @@ def test_labels_eo(self): self.assertEqual(form.fields['have_a_drink'].label, "Jes") self.assertTrue(hasattr(form.fields['have_a_drink'], 'extra_label')) self.assertEqual(form.fields['have_a_drink'].extra_label, "Trinkejumado") + self.assertEqual(form.fields['conditions'].label, "Kondiĉoj") self.assertEqual(form.fields['owner__first_name'].label, "Persona nomo") self.assertEqual(form.fields['owner__last_name'].label, "Familia nomo") - self.assertEqual( - form.fields['facilitations'].label, - "Montru gastigantojn kun jena faciligo") - self.assertEqual( - form.fields['restrictions'].label, - "Ne montru gastigantojn kun jena limigo") - def test_clean(self): # Checking the boolean fields on the form means # 'filter by these conditions' and is expected to diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py index fbd67092..9028eae4 100644 --- a/tests/forms/test_widgets.py +++ b/tests/forms/test_widgets.py @@ -8,16 +8,24 @@ from django.template import Context from django.test import TestCase, override_settings, tag +from bs4 import BeautifulSoup from factory import Faker from hosting.widgets import ( ClearableWithPreviewImageInput, CustomNullBooleanSelect, - InlineRadios, MultiNullBooleanSelects, TextWithDatalistInput, + ExpandedMultipleChoice, InlineRadios, + MultiNullBooleanSelects, TextWithDatalistInput, ) from maps.widgets import AdminMapboxGlWidget, MapboxGlWidget from ..assertions import AdditionalAsserts +HTML_PARSER = 'html.parser' + + +def safe_trim(value): + return value.strip() if isinstance(value, str) else value + @tag('forms', 'widgets') class ClearableWithPreviewImageInputWidgetTests(TestCase): @@ -112,27 +120,22 @@ def test_render_with_prefix(self): def test_css_class(self): widget = CustomNullBooleanSelect("Don't pick", self.NULL_BOOLEAN_CHOICES) - result = widget.render( - 'null_bool_field', "X", attrs={'id': 'id_bool_field', 'class': "fancy"}) - self.assertRegex( - result, - r']*class="\s*(not-fancy\s*form-control|form-control\s*not-fancy)\s*"' - ) - - result = widget.render( - 'null_bool_field', "Z", - attrs={'id': 'id_bool_field', 'class': "first-level form-control required"}) - self.assertRegex( - result, - r'