Skip to content

Render nullable BooleanField as a three-state select in the browsable… - #10083

Open
Danish-kh-IT wants to merge 3 commits into
encode:mainfrom
Danish-kh-IT:fix/7722-null-boolean-rendering
Open

Danish-kh-IT wants to merge 3 commits into
encode:mainfrom
Danish-kh-IT:fix/7722-null-boolean-rendering

Conversation

@Danish-kh-IT

Copy link
Copy Markdown

… 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's None value — an existing None shows 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_field now renders a nullable BooleanField with a new select_boolean.html template instead of checkbox.html, giving a three-state select: Unknown (""), Yes (True), No (False).
  • Added select_boolean.html to all three template packs (vertical, horizontal, inline).
  • BooleanField gains an iter_options() method (Unknown/Yes/No) and a _is_nullable_boolean_field marker used by the renderer.
  • BoundField.as_form_field() and NestedBoundField.as_form_field() now preserve False for nullable boolean fields (only None becomes ''), so the "No" option is correctly shown as selected.

Tests

  • test_rendering_nullable_boolean_field asserts the exact rendered HTML for True, False and None, including which option is selected.
  • Extended test_as_form_fields (simple and nested) to assert a nullable boolean's form value stays False.
  • test_nullable_boolean_html covers the HTML form parsing round-trip ('' -> None, 'True' -> True, 'False' -> False).

Full test suite: 1661 passed, 10 skipped. Pre-commit: all checks pass.

@auvipy
auvipy self-requested a review October 22, 2026 11:53
@auvipy auvipy added this to the 3.19 milestone Oct 22, 2026

@ege-arhan ege-arhan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@Danish-kh-IT

Copy link
Copy Markdown
Author

Thanks for the detailed review — all three points addressed in 30da075:

  1. Style merge: render_field now starts from self.default_style[field].copy(), sets base_template to select_boolean.html only for nullable booleans, then applies field.style on top, so explicit styles are honoured again. I folded it into a single copy/update flow rather than duplicating the branch — happy to match your suggested shape exactly if you prefer. Pinned by a new test: a nullable field styled with base_template: 'checkbox.html' renders the checkbox HTML, not the select.

  2. Property removed: _is_nullable_boolean_field is gone from fields.py; serializer_helpers.py now reads allow_null directly.

  3. Templates unified: all three packs now use the plain single-line {% if select.value == field.value %} comparison. Writing the packs test immediately caught a real bug here: the horizontal pack's {% if %} tag was split across two lines and raised TemplateSyntaxError, so it could never render — the inline pack had the same split. Both fixed. The new test renders all three packs for True/False/None and asserts the same option is selected in each.

Full suite: 1663 passed, 10 skipped. Pre-commit: all checks pass.

@ege-arhan ege-arhan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 rendered

Smaller 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 ege-arhan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 rendered

Smaller 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

Copy link
Copy Markdown

(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.)

inline/select_boolean.html already uses select.value|as_string == field.value|as_string. The other two packs copied select.html's plain ==, and select.html only gets away with that because ChoiceField stringifies its values — a BooleanField does not, hence the empty selection on redisplay. Fix needs both:

  1. as_string on both sides of the comparison in vertical/ and horizontal/select_boolean.html.
  2. {% load rest_framework %} at the top of those two files, since as_string lives in rest_framework.templatetags.rest_framework; without it Django raises TemplateSyntaxError: Invalid filter: 'as_string'.

After both changes: selected=1 for 'True', 'False' and '' on all three packs, suite green (1660 passed, 13 skipped).

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.
@Danish-kh-IT

Copy link
Copy Markdown
Author

Thanks for the careful re-check — you were right about the redisplay path; the valid-path tests never exercised it.

Pushed in 22cfd68:

  • All three select_boolean.html packs now compare select.value|as_string == field.value|as_string, so a raw submitted string ('True') matches again after a failed validation. Added the missing {% load rest_framework %} to the vertical and horizontal packs (inline already had it).
  • Added test_rendering_nullable_boolean_field_on_redisplay in tests/test_bound_fields.py: a required sibling field fails validation, and for each template pack the submitted 'True' / 'False' / '' option must stay selected.

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 ege-arhan left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

NullBooleanField should use forms.NullBooleanField just like django. And BooleanField should support default=True

3 participants