mirror of
https://github.com/encode/django-rest-framework.git
synced 2024-12-11 10:49:30 +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
|
radio.html | `ChoiceField` or relational field types | inline, hide_label
|
||||||
select_multiple.html | `MultipleChoiceField` or relational fields with `many=True` | 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_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
|
fieldset.html | Nested serializer | hide_label
|
||||||
list_fieldset.html | `ListField` or nested serializer with `many=True` | 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}
|
NULL_VALUES = {'null', 'Null', 'NULL', '', None}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _is_nullable_boolean_field(self):
|
||||||
|
return self.allow_null
|
||||||
|
|
||||||
def to_internal_value(self, data):
|
def to_internal_value(self, data):
|
||||||
try:
|
try:
|
||||||
if data in self.TRUE_VALUES:
|
if data in self.TRUE_VALUES:
|
||||||
|
@ -741,6 +745,14 @@ class BooleanField(Field):
|
||||||
return None
|
return None
|
||||||
return bool(value)
|
return bool(value)
|
||||||
|
|
||||||
|
def iter_options(self):
|
||||||
|
choices = {
|
||||||
|
"": _("Unknown"),
|
||||||
|
True: _("Yes"),
|
||||||
|
False: _("No"),
|
||||||
|
}
|
||||||
|
return iter_options(choices)
|
||||||
|
|
||||||
|
|
||||||
class NullBooleanField(BooleanField):
|
class NullBooleanField(BooleanField):
|
||||||
initial = None
|
initial = None
|
||||||
|
|
|
@ -329,6 +329,9 @@ class HTMLFormRenderer(BaseRenderer):
|
||||||
if isinstance(field._field, serializers.HiddenField):
|
if isinstance(field._field, serializers.HiddenField):
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
|
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 = self.default_style[field].copy()
|
||||||
style.update(field.style)
|
style.update(field.style)
|
||||||
if 'template_pack' not in style:
|
if 'template_pack' not in style:
|
||||||
|
|
|
@ -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,6 +76,9 @@ class BoundField:
|
||||||
)
|
)
|
||||||
|
|
||||||
def as_form_field(self):
|
def as_form_field(self):
|
||||||
|
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
|
value = '' if (self.value is None or self.value is False) else self.value
|
||||||
return self.__class__(self._field, value, self.errors, self._prefix)
|
return self.__class__(self._field, value, self.errors, self._prefix)
|
||||||
|
|
||||||
|
@ -129,6 +132,8 @@ class NestedBoundField(BoundField):
|
||||||
for key, value in self.value.items():
|
for key, value in self.value.items():
|
||||||
if isinstance(value, (list, dict)):
|
if isinstance(value, (list, dict)):
|
||||||
values[key] = value
|
values[key] = value
|
||||||
|
elif getattr(self.fields[key], '_is_nullable_boolean_field', False):
|
||||||
|
values[key] = '' if value is None else value
|
||||||
else:
|
else:
|
||||||
values[key] = '' if (value is None or value is False) else force_str(value)
|
values[key] = '' if (value is None or value is False) else force_str(value)
|
||||||
return self.__class__(self._field, values, self.errors, self._prefix)
|
return self.__class__(self._field, values, self.errors, self._prefix)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import pytest
|
||||||
from django.http import QueryDict
|
from django.http import QueryDict
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
@ -59,11 +60,13 @@ class TestSimpleBoundField:
|
||||||
def test_as_form_fields(self):
|
def test_as_form_fields(self):
|
||||||
class ExampleSerializer(serializers.Serializer):
|
class ExampleSerializer(serializers.Serializer):
|
||||||
bool_field = serializers.BooleanField()
|
bool_field = serializers.BooleanField()
|
||||||
|
nullable_bool_field = serializers.BooleanField(allow_null=True)
|
||||||
null_field = serializers.IntegerField(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.is_valid()
|
||||||
assert serializer['bool_field'].as_form_field().value == ''
|
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 == ''
|
assert serializer['null_field'].as_form_field().value == ''
|
||||||
|
|
||||||
def test_rendering_boolean_field(self):
|
def test_rendering_boolean_field(self):
|
||||||
|
@ -90,6 +93,55 @@ class TestSimpleBoundField:
|
||||||
rendered_packed = ''.join(rendered.split())
|
rendered_packed = ''.join(rendered.split())
|
||||||
assert rendered_packed == expected_packed
|
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):
|
class CustomJSONField(serializers.JSONField):
|
||||||
pass
|
pass
|
||||||
|
@ -120,6 +172,7 @@ class TestNestedBoundField:
|
||||||
def test_as_form_fields(self):
|
def test_as_form_fields(self):
|
||||||
class Nested(serializers.Serializer):
|
class Nested(serializers.Serializer):
|
||||||
bool_field = serializers.BooleanField()
|
bool_field = serializers.BooleanField()
|
||||||
|
nullable_bool_field = serializers.BooleanField(allow_null=True)
|
||||||
null_field = serializers.IntegerField(allow_null=True)
|
null_field = serializers.IntegerField(allow_null=True)
|
||||||
json_field = serializers.JSONField()
|
json_field = serializers.JSONField()
|
||||||
custom_json_field = CustomJSONField()
|
custom_json_field = CustomJSONField()
|
||||||
|
@ -129,12 +182,13 @@ class TestNestedBoundField:
|
||||||
|
|
||||||
serializer = ExampleSerializer(
|
serializer = ExampleSerializer(
|
||||||
data={'nested': {
|
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'},
|
'json_field': {'bool_item': True, 'number': 1, 'text_item': 'text'},
|
||||||
'custom_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.is_valid()
|
||||||
assert serializer['nested']['bool_field'].as_form_field().value == ''
|
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']['null_field'].as_form_field().value == ''
|
||||||
assert serializer['nested']['json_field'].as_form_field().value == '''{
|
assert serializer['nested']['json_field'].as_form_field().value == '''{
|
||||||
"bool_item": true,
|
"bool_item": true,
|
||||||
|
|
|
@ -364,6 +364,8 @@ class TestBooleanHTMLInput:
|
||||||
"""
|
"""
|
||||||
HTML checkboxes do not send any value, but should be treated
|
HTML checkboxes do not send any value, but should be treated
|
||||||
as `False` by BooleanField.
|
as `False` by BooleanField.
|
||||||
|
Note: BooleanFields are rendered as HTML checkboxes
|
||||||
|
only if allow_null=False.
|
||||||
"""
|
"""
|
||||||
class TestSerializer(serializers.Serializer):
|
class TestSerializer(serializers.Serializer):
|
||||||
archived = serializers.BooleanField()
|
archived = serializers.BooleanField()
|
||||||
|
@ -376,6 +378,8 @@ class TestBooleanHTMLInput:
|
||||||
"""
|
"""
|
||||||
HTML checkboxes do not send any value, but should be treated
|
HTML checkboxes do not send any value, but should be treated
|
||||||
as `False` by BooleanField, even if the field is required=False.
|
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):
|
class TestSerializer(serializers.Serializer):
|
||||||
archived = serializers.BooleanField(required=False)
|
archived = serializers.BooleanField(required=False)
|
||||||
|
@ -384,6 +388,22 @@ class TestBooleanHTMLInput:
|
||||||
assert serializer.is_valid()
|
assert serializer.is_valid()
|
||||||
assert serializer.validated_data == {'archived': False}
|
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:
|
class TestHTMLInput:
|
||||||
def test_empty_html_charfield_with_default(self):
|
def test_empty_html_charfield_with_default(self):
|
||||||
|
|
Loading…
Reference in New Issue
Block a user