Repository navigation
Render nullable BooleanField as a three-state select in the browsable… - #10083
Danish-kh-IT wants to merge 3 commits into
Conversation
ege-arhan
left a comment
There was a problem hiding this comment.
The nullable-Boolean rendering works for the default case, but render_field() now discards the field's own style whenever allow_null is set (rest_framework/renderers.py:333-337):
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)Any explicit style on such a field is ignored, not just base_template. Verified on the branch:
class S(serializers.Serializer):
styled = serializers.BooleanField(
allow_null=True,
style={'base_template': 'radio.html', 'autofocus': True},
)HTMLFormRenderer().render_field(BoundField(S().fields['styled'], None, {}, ''), {}) renders the <select>, so both the requested radio.html pack file and autofocus are lost — a behaviour change for existing API code that styles nullable booleans. template_pack also stops inheriting here, because the early style dict has no template_pack key and the if 'template_pack' not in style fallback below still applies, but the user's pack override does not.
Smaller thing: _is_nullable_boolean_field (rest_framework/fields.py:683-685) returns self.allow_null unchanged, and its only readers (rest_framework/utils/serializer_helpers.py:94,148) can use self._field.allow_null directly. Keeping the property means two ways to ask the same question, and the getattr(..., False) around it suggests the attribute may be missing on other field types while it is in fact defined on BooleanField only.
Suggested shape — merge instead of replace, and only as a default:
if isinstance(field._field, serializers.BooleanField) and field._field.allow_null:
style = self.default_style[field].copy()
style['base_template'] = 'select_boolean.html'
style.update(field.style)
else:
style = self.default_style[field].copy()
style.update(field.style)The three templates differ on the comparison operator as well: vertical/select_boolean.html and horizontal/select_boolean.html use {% if select.value == field.value %}, inline/select_boolean.html uses field.value|as_string. With allow_null the empty option is a ChoiceField-style blank whose value is '' while the bound value may be None, so the two packs can disagree about which option is marked selected; iter_options already yields as_string-comparable entries, and vertical/select.html uses the plain comparison, so one convention across all three would be worth pinning in the tests.
- renderers: merge the select_boolean.html default into the field's style instead of replacing it, so explicit styles (base_template, template_pack) are honoured again - fields/serializer_helpers: drop the _is_nullable_boolean_field property and use allow_null directly - templates: use the plain select.value == field.value comparison on a single line in all three select_boolean.html packs (the horizontal pack's split if tag did not parse at all) - tests: pin the explicit-style behaviour and the selected option across all three template packs
|
Thanks for the detailed review — all three points addressed in 30da075:
Full suite: 1663 passed, 10 skipped. Pre-commit: all checks pass. |
ege-arhan
left a comment
There was a problem hiding this comment.
Re-tested 30da075. Style merge is right now — base_template is only overridden as a default and style.update(field.style) runs after it, so an explicit radio.html still wins. One remaining bug though, and it is the one that matters in the browsable API.
The selection is lost on form redisplay. After a failed validation, BoundField.value is the raw submitted string, not a bool:
class S(serializers.Serializer):
f = serializers.BooleanField(allow_null=True)
other = serializers.CharField()
s = S(data={'f': 'True'}) # `other` missing -> invalid -> renderer redisplays the form
s.is_valid() # False
s['f'].value # 'True'BooleanField.iter_options() yields True/False as real bools, so in vertical/ and horizontal/select_boolean.html the comparison is True == 'True' → False for every option, and the only thing that matches is the blank option ('' == ''). Verified on the branch with the invalid-POST path, all three packs:
vertical raw='True' value='True' selected options: 1 -> Unknown
horizontal raw='True' value='True' selected options: 1 -> Unknown
inline raw='True' value='True' selected options: 1 -> Unknown (wrong for a different reason, see below)
So a user submits "Yes", the form comes back showing "Unknown" — worse than the current checkbox behaviour, since the checkbox at least round-trips.
inline/select_boolean.html already tries to defend against this with select.value|as_string == field.value|as_string, but that template does not {% load rest_framework %}... it does, sorry — it does load it. The real problem is that the other two packs copied select.html's plain comparison, while select.html itself only gets away with it because ChoiceField stringifies its values. For a BooleanField the comparison needs as_string on both sides in all three files:
{% for select in field.iter_options %}
{% if select.value|as_string == field.value|as_string %}and {% load rest_framework %} needs adding to vertical/ and horizontal/select_boolean.html, since as_string lives in rest_framework.templatetags.rest_framework and neither file loads it. Without the load tag Django raises TemplateSyntaxError: Invalid filter: 'as_string', so the template change alone is not enough:
django.template.exceptions.TemplateSyntaxError:
Template: rest_framework/vertical/select_boolean.html, Invalid filter: 'as_string'
With both applied I get selected=1 for 'True', 'False' and '' on every pack, and the full suite stays green (1660 passed, 13 skipped).
A regression test would be worth adding, since the current tests only cover the valid-data path (serializer.is_valid() then render, where value is still a bool). Something like:
class ExampleSerializer(serializers.Serializer):
bool_field = serializers.BooleanField(allow_null=True)
other = serializers.CharField()
serializer = ExampleSerializer(data={'bool_field': 'True'})
assert not serializer.is_valid()
rendered = ''.join(renderer.render_field(serializer['bool_field'], {}).split())
assert '<optionvalue="True"selected>' in renderedSmaller thing, no action needed for this PR but worth a follow-up: iter_options() always yields the ""/"Unknown" entry, even when allow_null=False. Only render_field gates on allow_null, so a non-nullable BooleanField that is explicitly styled with base_template: 'select.html' would now get an empty option that fails validation ('' is in NULL_VALUES, allow_null is False, so it raises invalid). Emitting Unknown only when self.allow_null would make the method safe to use on its own.
ege-arhan
left a comment
There was a problem hiding this comment.
Re-tested 30da075. Style merge is right now — base_template is only overridden as a default and style.update(field.style) runs after it, so an explicit radio.html still wins. One remaining bug though, and it is the one that matters in the browsable API.
The selection is lost on form redisplay. After a failed validation, BoundField.value is the raw submitted string, not a bool:
class S(serializers.Serializer):
f = serializers.BooleanField(allow_null=True)
other = serializers.CharField()
s = S(data={'f': 'True'}) # `other` missing -> invalid -> renderer redisplays the form
s.is_valid() # False
s['f'].value # 'True'BooleanField.iter_options() yields True/False as real bools, so in vertical/ and horizontal/select_boolean.html the comparison is True == 'True' → False for every option, and the only thing that matches is the blank option ('' == ''). Verified on the branch with the invalid-POST path, all three packs:
vertical raw='True' value='True' selected options: 1 -> Unknown
horizontal raw='True' value='True' selected options: 1 -> Unknown
inline raw='True' value='True' selected options: 1 -> Unknown (wrong for a different reason, see below)
So a user submits "Yes", the form comes back showing "Unknown" — worse than the current checkbox behaviour, since the checkbox at least round-trips.
inline/select_boolean.html does use select.value|as_string == field.value|as_string, but the other two packs copied select.html's plain comparison. select.html only gets away with that because ChoiceField stringifies its values; a BooleanField does not. The comparison needs as_string on both sides in all three files:
{% for select in field.iter_options %}
{% if select.value|as_string == field.value|as_string %}and {% load rest_framework %} needs adding to vertical/ and horizontal/select_boolean.html, since as_string lives in rest_framework.templatetags.rest_framework and neither file loads it. Without the load tag Django raises TemplateSyntaxError: Invalid filter: 'as_string', so the template change alone is not enough:
django.template.exceptions.TemplateSyntaxError:
Template: rest_framework/vertical/select_boolean.html, Invalid filter: 'as_string'
With both applied I get selected=1 for 'True', 'False' and '' on every pack, and the full suite stays green (1660 passed, 13 skipped).
A regression test would be worth adding, since the current tests only cover the valid-data path (serializer.is_valid() then render, where value is still a bool). Something like:
class ExampleSerializer(serializers.Serializer):
bool_field = serializers.BooleanField(allow_null=True)
other = serializers.CharField()
serializer = ExampleSerializer(data={'bool_field': 'True'})
assert not serializer.is_valid()
rendered = ''.join(renderer.render_field(serializer['bool_field'], {}).split())
assert '<optionvalue="True"selected>' in renderedSmaller thing, no action needed for this PR but worth a follow-up: iter_options() always yields the ""/"Unknown" entry, even when allow_null=False. Only render_field gates on allow_null, so a non-nullable BooleanField that is explicitly styled with base_template: 'select.html' would now get an empty option that fails validation ('' is in NULL_VALUES, allow_null is False, so it raises invalid). Emitting Unknown only when self.allow_null would make the method safe to use on its own.
|
(Meta: the review above got posted twice — I re-sent it while fixing a typo. GitHub does not allow editing or deleting a submitted review, so this comment is the corrected copy of the two paragraphs that were garbled in the duplicate. Findings are identical.)
After both changes: |
Compare the option and bound values as strings in select_boolean.html (all three template packs): after a failed validation the bound value is the raw submitted string rather than a bool, so a plain comparison matched only the blank option. Add the missing load tag for the as_string filter to the vertical and horizontal packs, and a regression test covering the invalid-data path in all three packs.
|
Thanks for the careful re-check — you were right about the redisplay path; the valid-path tests never exercised it. Pushed in 22cfd68:
Full suite: 1664 passed, 10 skipped. On the iter_options follow-up: agreed it would be cleaner if the Unknown entry were only yielded when allow_null — leaving that out of this PR as you suggested. |
ege-arhan
left a comment
There was a problem hiding this comment.
Re-checked 22cfd68 against the redisplay point from my earlier review: all three select_boolean.html packs now compare through |as_string and the vertical/horizontal packs load rest_framework, so a raw submitted string ("True"/"False"/"") stays selected after a failed validation. test_rendering_nullable_boolean_field_on_redisplay covers each pack. Nothing further from my side; the remaining sign-off is a maintainer call.
… API
Fixes #7722.
Problem
In the browsable API, a
BooleanField(allow_null=True)is rendered as a checkbox. A checkbox has only two states, so it cannot represent the field'sNonevalue — an existingNoneshows as unchecked, and there is no way to submit "unknown" from the form.Fix
This revives the approach from the earlier attempt in #7882, which was closed unmerged:
HTMLFormRenderer.render_fieldnow renders a nullableBooleanFieldwith a newselect_boolean.htmltemplate instead ofcheckbox.html, giving a three-state select: Unknown (""), Yes (True), No (False).select_boolean.htmlto all three template packs (vertical, horizontal, inline).BooleanFieldgains aniter_options()method (Unknown/Yes/No) and a_is_nullable_boolean_fieldmarker used by the renderer.BoundField.as_form_field()andNestedBoundField.as_form_field()now preserveFalsefor nullable boolean fields (onlyNonebecomes ''), so the "No" option is correctly shown as selected.Tests
test_rendering_nullable_boolean_fieldasserts the exact rendered HTML for True, False and None, including which option is selected.test_as_form_fields(simple and nested) to assert a nullable boolean's form value stays False.test_nullable_boolean_htmlcovers the HTML form parsing round-trip ('' -> None, 'True' -> True, 'False' -> False).Full test suite: 1661 passed, 10 skipped. Pre-commit: all checks pass.