Repository: wagtail/wagtail

Issue:
blocks.BooleanBlock not working in Streamfield in 2.13rc1
### Issue Summary

Whenever I select blocks.BooleanBlock in a streamfield, when saved, the value is not stored.

### Steps to Reproduce

```
from django.utils.translation import ugettext_lazy as _

from wagtail.core import blocks


class BlockPluginWrapper(blocks.StructBlock):
    plugin_background = blocks.BooleanBlock(
        required=False,
        default=False,
        help_text=_('if true, the background will be gray.'),
        group='wrapper'
    )
    plugin_title = blocks.CharBlock(
        max_length=150,
        required=False,
        help_text=_('optional'),
        group='wrapper'
    )

    def clean(self, value):
        print(value)

        return super().clean(value)

    class Meta:
        form_template = 'blocks/block_plugin_wrapper.html'
```

In the clean method the following is printed, even though plugin_background should be True:

```
StructValue([('plugin_background', False), ('plugin_title', 'test')])
```

Streamfield:

```
content = StreamField(
        [
            ('test', BlockPluginWrapper())
        ],
        null=True,
        blank=True
    )
```

### Technical details

* Python version: 3.7.9
* Django version: 3.1.8
* Wagtail version: 2.13rc1



Discussion:
Thanks for the report @Warqus! I think I see what's going on here - we didn't specify any special case handling for checkbox inputs in the front-end code, so they're falling into the general handling of `<input>` elements, which includes populating the initial state of the field by setting the `value` property. For checkboxes, this is incorrect - it should be setting `checked`. As it stands, we're setting the value of an initially-unset checkbox to none, which means that even when it's checked it will never be recognised as a 'true' value. Should hopefully be a quick fix...

Resolving patch:
diff --git a/wagtail/core/blocks/field_block.py b/wagtail/core/blocks/field_block.py
--- a/wagtail/core/blocks/field_block.py
+++ b/wagtail/core/blocks/field_block.py
@@ -258,6 +258,12 @@ def __init__(self, required=True, help_text=None, **kwargs):
         self.field = forms.BooleanField(required=required, help_text=help_text)
         super().__init__(**kwargs)
 
+    def get_form_state(self, value):
+        # Bypass widget.format_value, because CheckboxInput uses that to prepare the "value"
+        # attribute (as distinct from the "checked" attribute that represents the actual checkbox
+        # state, which it handles in get_context).
+        return bool(value)
+
     class Meta:
         icon = "tick-inverse"
 
diff --git a/wagtail/core/widget_adapters.py b/wagtail/core/widget_adapters.py
--- a/wagtail/core/widget_adapters.py
+++ b/wagtail/core/widget_adapters.py
@@ -37,6 +37,13 @@ def media(self):
 register(WidgetAdapter(), forms.CheckboxSelectMultiple)
 
 
+class CheckboxInputAdapter(WidgetAdapter):
+    js_constructor = 'wagtail.widgets.CheckboxInput'
+
+
+register(CheckboxInputAdapter(), forms.CheckboxInput)
+
+
 class RadioSelectAdapter(WidgetAdapter):
     js_constructor = 'wagtail.widgets.RadioSelect'
 


Test patch:
diff --git a/client/src/entrypoints/admin/telepath/__snapshots__/widgets.test.js.snap b/client/src/entrypoints/admin/telepath/__snapshots__/widgets.test.js.snap
--- a/client/src/entrypoints/admin/telepath/__snapshots__/widgets.test.js.snap
+++ b/client/src/entrypoints/admin/telepath/__snapshots__/widgets.test.js.snap
@@ -1,5 +1,7 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`telepath: wagtail.widgets.CheckboxInput it renders correctly 1`] = `"<input type=\\"checkbox\\" name=\\"sugar\\" id=\\"id-sugar\\" checked=\\"checked\\">"`;
+
 exports[`telepath: wagtail.widgets.PageChooser it renders correctly 1`] = `
 "<div id=\\"the-id-chooser\\" class=\\"chooser page-chooser\\" data-chooser-url=\\"/admin/choose-page/\\">
           <div class=\\"chosen\\">
diff --git a/client/src/entrypoints/admin/telepath/widgets.test.js b/client/src/entrypoints/admin/telepath/widgets.test.js
--- a/client/src/entrypoints/admin/telepath/widgets.test.js
+++ b/client/src/entrypoints/admin/telepath/widgets.test.js
@@ -107,6 +107,51 @@ describe('telepath: wagtail.widgets.RadioSelect', () => {
   });
 });
 
+describe('telepath: wagtail.widgets.CheckboxInput', () => {
+  let boundWidget;
+
+  beforeEach(() => {
+    // Create a placeholder to render the widget
+    document.body.innerHTML = '<div id="placeholder"></div>';
+
+    // Unpack and render a radio select widget
+    const widgetDef = window.telepath.unpack({
+      _type: 'wagtail.widgets.CheckboxInput',
+      _args: [
+        '<input type="checkbox" name="__NAME__" id="__ID__">',
+        '__ID__'
+      ]
+    });
+    boundWidget = widgetDef.render($('#placeholder'), 'sugar', 'id-sugar', true);
+  });
+
+  test('it renders correctly', () => {
+    expect(document.body.innerHTML).toMatchSnapshot();
+    expect(document.querySelector('input[id="id-sugar"]').checked).toBe(true);
+  });
+
+  test('getValue() returns the current value', () => {
+    expect(boundWidget.getValue()).toBe(true);
+  });
+
+  test('getState() returns the current state', () => {
+    expect(boundWidget.getState()).toBe(true);
+  });
+
+  test('setState() changes the current state', () => {
+    boundWidget.setState(false);
+    expect(document.querySelector('input[id="id-sugar"]').checked).toBe(false);
+    boundWidget.setState(true);
+    expect(document.querySelector('input[id="id-sugar"]').checked).toBe(true);
+  });
+
+  test('focus() focuses the checkbox', () => {
+    boundWidget.focus();
+
+    expect(document.activeElement).toBe(document.querySelector('input[id="id-sugar"]'));
+  });
+});
+
 describe('telepath: wagtail.widgets.PageChooser', () => {
   let boundWidget;
 
diff --git a/wagtail/core/tests/test_blocks.py b/wagtail/core/tests/test_blocks.py
--- a/wagtail/core/tests/test_blocks.py
+++ b/wagtail/core/tests/test_blocks.py
@@ -321,6 +321,15 @@ def validate_is_example_domain(value):
             block.clean("foo@example.net")
 
 
+class TestBooleanBlock(unittest.TestCase):
+    def test_get_form_state(self):
+        block = blocks.BooleanBlock(required=False)
+        form_state = block.get_form_state(True)
+        self.assertEqual(form_state, True)
+        form_state = block.get_form_state(False)
+        self.assertEqual(form_state, False)
+
+
 class TestBlockQuoteBlock(unittest.TestCase):
     def test_render(self):
         block = blocks.BlockQuoteBlock()