Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Render BooleanFields with allow_null=True as HTML select rather than as
HTML checkbox in Browsable API encode#7722.
  • Loading branch information
tbrknt committed Mar 29, 2021
1 parent 0323d6f commit 9faac60
Show file tree
Hide file tree
Showing 9 changed files with 164 additions and 6 deletions.
3 changes: 2 additions & 1 deletion docs/topics/html-and-forms.md
Expand Up @@ -215,6 +215,7 @@ select.html | `ChoiceField` or relational field types | hide_label
radio.html | `ChoiceField` or relational field types | inline, hide_label
select_multiple.html | `MultipleChoiceField` or relational fields with `many=True` | hide_label
checkbox_multiple.html | `MultipleChoiceField` or relational fields with `many=True` | inline, hide_label
checkbox.html | `BooleanField` | hide_label
checkbox.html | `BooleanField` with `allow_null=False` | hide_label
select_boolean.html | `BooleanField` with `allow_null=True` | hide_label
fieldset.html | Nested serializer | hide_label
list_fieldset.html | `ListField` or nested serializer with `many=True` | hide_label
12 changes: 12 additions & 0 deletions rest_framework/fields.py
Expand Up @@ -720,6 +720,10 @@ class BooleanField(Field):
}
NULL_VALUES = {'null', 'Null', 'NULL', '', None}

@property
def _is_nullable_boolean_field(self):
return self.allow_null

def to_internal_value(self, data):
try:
if data in self.TRUE_VALUES:
Expand All @@ -741,6 +745,14 @@ def to_representation(self, value):
return None
return bool(value)

def iter_options(self):
choices = {
"": _("Unknown"),
True: _("Yes"),
False: _("No"),
}
return iter_options(choices)


class NullBooleanField(BooleanField):
initial = None
Expand Down
7 changes: 5 additions & 2 deletions rest_framework/renderers.py
Expand Up @@ -328,8 +328,11 @@ class HTMLFormRenderer(BaseRenderer):
def render_field(self, field, parent_style):
if isinstance(field._field, serializers.HiddenField):
return ''

style = self.default_style[field].copy()
is_nullable_boolean_field = isinstance(field._field, serializers.BooleanField) and field._field.allow_null
if is_nullable_boolean_field:
style = {'base_template': 'select_boolean.html'}
else:
style = self.default_style[field].copy()
style.update(field.style)
if 'template_pack' not in style:
style['template_pack'] = parent_style.get('template_pack', self.template_pack)
Expand Down
@@ -0,0 +1,25 @@

<div class="form-group">
{% if field.label %}
<label class="col-sm-2 control-label {% if style.hide_label %}sr-only{% endif %}">
{{ field.label }}
</label>
{% endif %}

<div class="col-sm-10">
<select class="form-control" name="{{ field.name }}">
{% for select in field.iter_options %}
<option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
{% endfor %}
</select>
{% if field.errors %}
{% for error in field.errors %}
<span class="help-block">{{ error }}</span>
{% endfor %}
{% endif %}

{% if field.help_text %}
<span class="help-block">{{ field.help_text|safe }}</span>
{% endif %}
</div>
</div>
15 changes: 15 additions & 0 deletions rest_framework/templates/rest_framework/inline/select_boolean.html
@@ -0,0 +1,15 @@
{% load rest_framework %}

<div class="form-group {% if field.errors %}has-error{% endif %}">
{% if field.label %}
<label class="sr-only">
{{ field.label }}
</label>
{% endif %}

<select class="form-control" name="{{ field.name }}">
{% for select in field.iter_options %}
<option value="{{ select.value }}" {% if select.value|as_string == field.value|as_string %}selected{% endif %} >{{ select.display_text }}</option>
{% endfor %}
</select>
</div>
@@ -0,0 +1,23 @@
<div class="form-group {% if field.errors %}has-error{% endif %}">
{% if field.label %}
<label {% if style.hide_label %}class="sr-only"{% endif %}>
{{ field.label }}
</label>
{% endif %}

<select class="form-control" name="{{ field.name }}">
{% for select in field.iter_options %}
<option value="{{ select.value }}" {% if select.value == field.value %}selected{% endif %}>{{ select.display_text }}</option>
{% endfor %}
</select>

{% if field.errors %}
{% for error in field.errors %}
<span class="help-block">{{ error }}</span>
{% endfor %}
{% endif %}

{% if field.help_text %}
<span class="help-block">{{ field.help_text|safe }}</span>
{% endif %}
</div>
7 changes: 6 additions & 1 deletion rest_framework/utils/serializer_helpers.py
Expand Up @@ -76,7 +76,10 @@ def __repr__(self):
)

def as_form_field(self):
value = '' if (self.value is None or self.value is False) else self.value
if getattr(self._field, '_is_nullable_boolean_field', False):
value = '' if self.value is None else self.value
else:
value = '' if (self.value is None or self.value is False) else self.value
return self.__class__(self._field, value, self.errors, self._prefix)


Expand Down Expand Up @@ -129,6 +132,8 @@ def as_form_field(self):
for key, value in self.value.items():
if isinstance(value, (list, dict)):
values[key] = value
elif getattr(self.fields[key], '_is_nullable_boolean_field', False):
values[key] = '' if value is None else value
else:
values[key] = '' if (value is None or value is False) else force_str(value)
return self.__class__(self._field, values, self.errors, self._prefix)
Expand Down
58 changes: 56 additions & 2 deletions tests/test_bound_fields.py
@@ -1,3 +1,4 @@
import pytest
from django.http import QueryDict

from rest_framework import serializers
Expand Down Expand Up @@ -59,11 +60,13 @@ class ExampleSerializer(serializers.Serializer):
def test_as_form_fields(self):
class ExampleSerializer(serializers.Serializer):
bool_field = serializers.BooleanField()
nullable_bool_field = serializers.BooleanField(allow_null=True)
null_field = serializers.IntegerField(allow_null=True)

serializer = ExampleSerializer(data={'bool_field': False, 'null_field': None})
serializer = ExampleSerializer(data={'bool_field': False, 'nullable_bool_field': False, 'null_field': None})
assert serializer.is_valid()
assert serializer['bool_field'].as_form_field().value == ''
assert serializer['nullable_bool_field'].as_form_field().value is False
assert serializer['null_field'].as_form_field().value == ''

def test_rendering_boolean_field(self):
Expand All @@ -90,6 +93,55 @@ class ExampleSerializer(serializers.Serializer):
rendered_packed = ''.join(rendered.split())
assert rendered_packed == expected_packed

@pytest.mark.parametrize('bool_field_value', [True, False, None])
def test_rendering_nullable_boolean_field(self, bool_field_value):
from rest_framework.renderers import HTMLFormRenderer

class ExampleSerializer(serializers.Serializer):
bool_field = serializers.BooleanField(
allow_null=True,
style={'base_template': 'select_boolean.html', 'template_pack': 'rest_framework/vertical'})

serializer = ExampleSerializer(data={'bool_field': bool_field_value})
assert serializer.is_valid()
renderer = HTMLFormRenderer()
rendered = renderer.render_field(serializer['bool_field'], {})
if bool_field_value is True:
expected_packed = (
'<divclass="form-group">'
'<label>Boolfield</label>'
'<selectclass="form-control"name="bool_field">'
'<optionvalue="">Unknown</option>'
'<optionvalue="True"selected>Yes</option>'
'<optionvalue="False">No</option>'
'</select>'
'</div>'
)
elif bool_field_value is False:
expected_packed = (
'<divclass="form-group">'
'<label>Boolfield</label>'
'<selectclass="form-control"name="bool_field">'
'<optionvalue="">Unknown</option>'
'<optionvalue="True">Yes</option>'
'<optionvalue="False"selected>No</option>'
'</select>'
'</div>'
)
elif bool_field_value is None:
expected_packed = (
'<divclass="form-group">'
'<label>Boolfield</label>'
'<selectclass="form-control"name="bool_field">'
'<optionvalue=""selected>Unknown</option>'
'<optionvalue="True">Yes</option>'
'<optionvalue="False">No</option>'
'</select>'
'</div>'
)
rendered_packed = ''.join(rendered.split())
assert rendered_packed == expected_packed


class CustomJSONField(serializers.JSONField):
pass
Expand Down Expand Up @@ -120,6 +172,7 @@ class ExampleSerializer(serializers.Serializer):
def test_as_form_fields(self):
class Nested(serializers.Serializer):
bool_field = serializers.BooleanField()
nullable_bool_field = serializers.BooleanField(allow_null=True)
null_field = serializers.IntegerField(allow_null=True)
json_field = serializers.JSONField()
custom_json_field = CustomJSONField()
Expand All @@ -129,12 +182,13 @@ class ExampleSerializer(serializers.Serializer):

serializer = ExampleSerializer(
data={'nested': {
'bool_field': False, 'null_field': None,
'bool_field': False, 'nullable_bool_field': False, 'null_field': None,
'json_field': {'bool_item': True, 'number': 1, 'text_item': 'text'},
'custom_json_field': {'bool_item': True, 'number': 1, 'text_item': 'text'},
}})
assert serializer.is_valid()
assert serializer['nested']['bool_field'].as_form_field().value == ''
assert serializer['nested']['nullable_bool_field'].as_form_field().value is False
assert serializer['nested']['null_field'].as_form_field().value == ''
assert serializer['nested']['json_field'].as_form_field().value == '''{
"bool_item": true,
Expand Down
20 changes: 20 additions & 0 deletions tests/test_fields.py
Expand Up @@ -364,6 +364,8 @@ def test_empty_html_checkbox(self):
"""
HTML checkboxes do not send any value, but should be treated
as `False` by BooleanField.
Note: BooleanFields are rendered as HTML checkboxes
only if allow_null=False.
"""
class TestSerializer(serializers.Serializer):
archived = serializers.BooleanField()
Expand All @@ -376,6 +378,8 @@ def test_empty_html_checkbox_not_required(self):
"""
HTML checkboxes do not send any value, but should be treated
as `False` by BooleanField, even if the field is required=False.
Note: BooleanFields are rendered as HTML checkboxes
only if allow_null=False.
"""
class TestSerializer(serializers.Serializer):
archived = serializers.BooleanField(required=False)
Expand All @@ -384,6 +388,22 @@ class TestSerializer(serializers.Serializer):
assert serializer.is_valid()
assert serializer.validated_data == {'archived': False}

@pytest.mark.parametrize(('select_option_value', 'expected_internal_value'), (('', None), ('True', True), ('False', False)))
def test_nullable_boolean_html(self, select_option_value, expected_internal_value):
"""
If allow_null=True, BooleanField is rendered as HTML select element
containing three option elements with values '', 'True', and 'False'.
If option value=False selected, the internal value False is expected.
If option value=True selected, the internal value True is expected.
If option value= (the empty string) selected, the internal value None is expected.
"""
class TestSerializer(serializers.Serializer):
archived = serializers.BooleanField(allow_null=True)

serializer = TestSerializer(data=QueryDict(f'archived={select_option_value}'))
assert serializer.is_valid()
assert serializer.validated_data == {'archived': expected_internal_value}


class TestHTMLInput:
def test_empty_html_charfield_with_default(self):
Expand Down

0 comments on commit 9faac60

Please sign in to comment.