mirror of
https://github.com/encode/django-rest-framework.git
synced 2024-12-01 05:54:01 +03:00
Fix #7722.
Render BooleanFields with allow_null=True as HTML select rather than as HTML checkbox in Browsable API #7722.
This commit is contained in:
parent
0323d6f895
commit
5c9bd01b5f
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue
Block a user