Render BooleanFields with allow_null=True as HTML select rather than as
HTML checkbox in Browsable API #7722.
This commit is contained in:
Berkant Kepez 2021-03-26 18:11:58 +01:00
parent 0323d6f895
commit 5c9bd01b5f
9 changed files with 163 additions and 5 deletions

View File

@ -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

View File

@ -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:
@ -741,6 +745,14 @@ class BooleanField(Field):
return None
return bool(value)
def iter_options(self):
choices = {
"": _("Unknown"),
True: _("Yes"),
False: _("No"),
}
return iter_options(choices)
class NullBooleanField(BooleanField):
initial = None

View File

@ -329,7 +329,10 @@ class HTMLFormRenderer(BaseRenderer):
if isinstance(field._field, serializers.HiddenField):
return ''
style = self.default_style[field].copy()
if isinstance(field._field, serializers.BooleanField) and field._field.allow_null:
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)

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -76,7 +76,10 @@ class BoundField:
)
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)
@ -129,6 +132,8 @@ class NestedBoundField(BoundField):
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)

View File

@ -1,3 +1,4 @@
import pytest
from django.http import QueryDict
from rest_framework import serializers
@ -59,11 +60,13 @@ class TestSimpleBoundField:
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):
@ -90,6 +93,55 @@ class TestSimpleBoundField:
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
@ -120,6 +172,7 @@ class TestNestedBoundField:
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()
@ -129,12 +182,13 @@ class TestNestedBoundField:
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,

View File

@ -364,6 +364,8 @@ class TestBooleanHTMLInput:
"""
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()
@ -376,6 +378,8 @@ class TestBooleanHTMLInput:
"""
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)
@ -384,6 +388,22 @@ class TestBooleanHTMLInput:
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('archived={}'.format(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):