Repository: django-crispy-forms/django-crispy-forms

Issue:
Checkbox validation broken (bootstrap4)
* Package version: 1.7.2
* Django version: 1.11
* Python version: 3.6
* Template pack: bootstrap4

### Description:

`CrispyFieldNode` does not add 'is-invalid' class to the checkbox field with errors, so `span.invalid-feedback` stays invisible.

According to [bootstrap documentation](https://getbootstrap.com/docs/4.3/components/forms/#server-side), html must look like this:

```html
<div class="form-group">
    <div class="form-check">
      <input class="form-check-input is-invalid" type="checkbox" value="" id="invalidCheck3" required>
      <label class="form-check-label" for="invalidCheck3">
        Agree to terms and conditions
      </label>
      <div class="invalid-feedback">
        You must agree before submitting.
      </div>
    </div>
  </div>
```

but in `crispy_forms/templatetags/crispy_forms_field.py` (lines 132:140) I found this:

```python
            if (
                template_pack == 'bootstrap4'
                and not is_checkbox(field)
                and not is_file(field)
                and not is_multivalue(field)
            ):
                css_class += ' form-control'
                if field.errors:
                    css_class += ' is-invalid'
```
so checkboxes will never have `is-invalid` class and error message will stay invisible.


Discussion:
There is another part to this bug:

crispy_forms/templates/bootstrap4/field.html line 30 puts the `<input>` inside the `<label>` 

https://github.com/django-crispy-forms/django-crispy-forms/blob/c8310b496028099956262495abbff74640beee9f/crispy_forms/templates/bootstrap4/field.html#L28-L33

Adding `is-invalid` css to the `<input>` will not cause the error to be displayed if the `<input>` is inside the `<label>`. 

According to the bootstrap4 example the correct location of the input is shown **before** the label:
https://getbootstrap.com/docs/4.0/components/forms/#server-side

When using https://github.com/django-crispy-forms/crispy-test-project/pull/9 we see that it is not limited to checkbox, but also MultipleChoiceField rendered with CheckboxSelectMultiple and ChoiceField rendered with RadioSelect. You patch fixes the single CheckBox validation, here after the rendering using your patch.
![screencapture-127-0-0-1-8088-2019-04-10-16_32_19](https://user-images.githubusercontent.com/11556772/55887722-4b766c00-5bae-11e9-83a0-6b03637e7800.png)
(done using https://github.com/django-crispy-forms/crispy-test-project/pull/9)
The class for radio and checkbox need to use the custom bootstrap styles. The current radio / checkbox don't seem to be bootstrap style (the new ones look quite different)
 [demo @ codepen](https://codepen.io/david_smith/pen/yLBKeJG)

Old
![Capture2](https://user-images.githubusercontent.com/39445562/64562361-c225ba00-d344-11e9-92ad-48d396fe0a4d.PNG)

New
![Capture3](https://user-images.githubusercontent.com/39445562/64562362-c2be5080-d344-11e9-8d8f-182dac05ecd2.PNG)

**Reference material**
[Bootstrap Documentation](https://getbootstrap.com/docs/4.3/components/forms/#checkboxes-and-radios-1)

Bootstrap version  = 4.3.1

Required classes:

**Radio**
Div : custom-control custom-radio
Input : custom-control-input _is-invalid_
Label : custom-control-label


**Checkbox**
Div : custom-control custom-checkbox
Input : custom-control-input _is-invalid_
Label : custom-control-label



Resolving patch:
diff --git a/crispy_forms/helper.py b/crispy_forms/helper.py
--- a/crispy_forms/helper.py
+++ b/crispy_forms/helper.py
@@ -215,6 +215,7 @@ def helper(self):
     template = None
     field_template = None
     disable_csrf = False
+    use_custom_control = True
     label_class = ''
     field_class = ''
     include_media = True
@@ -364,6 +365,7 @@ def get_attributes(self, template_pack=TEMPLATE_PACK):
             'html5_required': self.html5_required,
             'form_show_labels': self.form_show_labels,
             'disable_csrf': self.disable_csrf,
+            'use_custom_control': self.use_custom_control,
             'label_class': self.label_class,
             'field_class': self.field_class,
             'include_media': self.include_media
diff --git a/crispy_forms/templatetags/crispy_forms_field.py b/crispy_forms/templatetags/crispy_forms_field.py
--- a/crispy_forms/templatetags/crispy_forms_field.py
+++ b/crispy_forms/templatetags/crispy_forms_field.py
@@ -131,10 +131,10 @@ def render(self, context):
 
             if (
                 template_pack == 'bootstrap4'
-                and not is_checkbox(field)
                 and not is_multivalue(field)
             ):
-                css_class += ' form-control'
+                if not is_checkbox(field):
+                    css_class += ' form-control'
                 if field.errors:
                     css_class += ' is-invalid'
 


Test patch:
diff --git a/crispy_forms/tests/test_form_helper.py b/crispy_forms/tests/test_form_helper.py
--- a/crispy_forms/tests/test_form_helper.py
+++ b/crispy_forms/tests/test_form_helper.py
@@ -69,8 +69,10 @@ def test_inputs(settings):
         assert 'class="btn"' in html
         assert 'btn btn-primary' in html
         assert 'btn btn-inverse' in html
-        assert len(re.findall(r'<input[^>]+> <', html)) == 8
-
+        if settings.CRISPY_TEMPLATE_PACK == 'bootstrap4':
+            assert len(re.findall(r'<input[^>]+> <', html)) == 9
+        else:
+            assert len(re.findall(r'<input[^>]+> <', html)) == 8
 
 def test_invalid_form_method():
     form_helper = FormHelper()
diff --git a/crispy_forms/tests/test_layout.py b/crispy_forms/tests/test_layout.py
--- a/crispy_forms/tests/test_layout.py
+++ b/crispy_forms/tests/test_layout.py
@@ -566,7 +566,51 @@ def test_keepcontext_context_manager(settings):
     elif settings.CRISPY_TEMPLATE_PACK == 'bootstrap3':
         assert response.content.count(b'checkbox-inline') == 3
     elif settings.CRISPY_TEMPLATE_PACK == 'bootstrap4':
-        assert response.content.count(b'form-check-inline') == 3
+        assert response.content.count(b'custom-control-inline') == 3
+        assert response.content.count(b'custom-checkbox') > 0
+
+
+@only_bootstrap4
+def test_use_custom_control_is_used():
+    form = CheckboxesSampleForm()
+    form.helper = FormHelper()
+    form.helper.layout = Layout(
+        'checkboxes',
+        InlineCheckboxes('alphacheckboxes'),
+        'numeric_multiple_checkboxes'
+    )
+    # form.helper.use_custom_control take default value which is True
+
+    response = render(
+        request=None,
+        template_name='crispy_render_template.html',
+        context={'form': form}
+    )
+    assert response.content.count(b'custom-control-inline') == 3
+    assert response.content.count(b'custom-checkbox') == 9
+
+    form.helper.use_custom_control = True
+
+    response = render(
+        request=None,
+        template_name='crispy_render_template.html',
+        context={'form': form}
+    )
+    assert response.content.count(b'custom-control-inline') == 3
+    assert response.content.count(b'custom-checkbox') == 9
+
+    form.helper.use_custom_control = False
+
+    response = render(
+        request=None,
+        template_name='crispy_render_template.html',
+        context={'form': form}
+    )
+
+    assert response.content.count(b'custom-control-inline') == 0
+    assert response.content.count(b'form-check-inline') == 3
+    assert response.content.count(b'form-check') > 0
+    assert response.content.count(b'custom-checkbox') == 0
 
 
 @only_bootstrap3
diff --git a/crispy_forms/tests/test_layout_objects.py b/crispy_forms/tests/test_layout_objects.py
--- a/crispy_forms/tests/test_layout_objects.py
+++ b/crispy_forms/tests/test_layout_objects.py
@@ -150,9 +150,9 @@ class CustomCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
         form.helper = FormHelper()
         form.helper.layout = Layout('inline_radios')
 
-        html = render_crispy_form(form)
+        html = render_crispy_form(form)  
         if settings.CRISPY_TEMPLATE_PACK == 'bootstrap4':
-            assert 'class="form-check"' in html
+            assert 'class="custom-control-input"' in html
         else:
             assert 'class="radio"' in html
 
@@ -161,7 +161,7 @@ class CustomCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
         form.helper.layout = Layout('checkboxes')
         html = render_crispy_form(form)
         if settings.CRISPY_TEMPLATE_PACK == 'bootstrap4':
-            assert 'class="form-check"' in html
+            assert 'class="custom-control-input"' in html
         else:
             assert 'class="checkbox"' in html
 
@@ -225,7 +225,7 @@ def test_inline_radios(self, settings):
         elif settings.CRISPY_TEMPLATE_PACK == 'bootstrap3':
             assert html.count('radio-inline"') == 2
         elif settings.CRISPY_TEMPLATE_PACK == 'bootstrap4':
-            assert html.count('form-check-inline"') == 2
+            assert html.count('custom-control-inline"') == 2
 
     def test_accordion_and_accordiongroup(self, settings):
         test_form = SampleForm()
@@ -487,7 +487,7 @@ def test_multiplecheckboxes(self, settings):
             if settings.CRISPY_TEMPLATE_PACK == 'bootstrap3':
                 assert html.count('checkbox-inline"') == 3
             elif settings.CRISPY_TEMPLATE_PACK == 'bootstrap4':
-                assert html.count('form-check-inline"') == 3
+                assert html.count('custom-control-inline"') == 3
 
     def test_multiple_checkboxes_unique_ids(self):
         test_form = CheckboxesSampleForm()