Repository: zostera/django-bootstrap4

Issue:
Field error does not render when addon_before or addon_after is used
Field error does not render when addon_before or addon_after is used, though it renders the error css style (in-valid) but the error message does not show

Example

`{% bootstrap_field form.user addon_after="<div class='side-pop-btn-group'><a href='javascript:;' data-type='user-edit' class='p-2 text-warning' data-toggle='tooltip' title='Edit User'><i class='fal fa-pencil-alt'></i></a><a href='javascript:;' class='p-2 text-success' data-type='user-add' data-toggle='tooltip' title='Add User'><i class='fas fa-plus'></i></a><a href='javascript:;' class='p-2 text-danger' data-type='user-delete' data-toggle='tooltip' title='Delete User'><i class='fas fa-times'></i></a></div>"  %}
        `
        


Discussion:

Resolving patch:
diff --git a/bootstrap4/renderers.py b/bootstrap4/renderers.py
--- a/bootstrap4/renderers.py
+++ b/bootstrap4/renderers.py
@@ -435,7 +435,8 @@ def make_input_group_addon(self, inner_class, outer_class, content):
             addon_class=outer_class, addon=content
         )
 
-    def make_input_group(self, html):
+    @property
+    def is_input_group(self):
         allowed_widget_types = (
             TextInput,
             PasswordInput,
@@ -444,10 +445,14 @@ def make_input_group(self, html):
             Select,
             EmailInput,
         )
-        if (self.addon_before or self.addon_after) and isinstance(
-            self.widget, allowed_widget_types
-        ):
-            html = '<div class="input-group">{before}{html}{after}</div>'.format(
+        return (
+            (self.addon_before or self.addon_after)
+            and isinstance(self.widget, allowed_widget_types)
+        )
+
+    def make_input_group(self, html):
+        if self.is_input_group:
+            html = '{before}{html}{after}'.format(
                 before=self.make_input_group_addon(
                     self.addon_before_class, "input-group-prepend", self.addon_before
                 ),
@@ -456,18 +461,18 @@ def make_input_group(self, html):
                 ),
                 html=html,
             )
+            html = self.append_errors(html)
+            html = '<div class="input-group">{html}</div>'.format(html=html)
         return html
 
-    def append_help_and_error(self, html):
+    def append_help(self, html):
         field_help = self.field_help or None
-        field_errors = self.field_errors
-        if field_help or field_errors:
+        if field_help:
             help_html = render_template_file(
-                "bootstrap4/field_help_text_and_errors.html",
+                "bootstrap4/field_help_text.html",
                 context={
                     "field": self.field,
                     "field_help": field_help,
-                    "field_errors": field_errors,
                     "layout": self.layout,
                     "show_help": self.show_help,
                 },
@@ -475,15 +480,40 @@ def append_help_and_error(self, html):
             html += help_html
         return html
 
+    def append_errors(self, html):
+        field_errors = self.field_errors
+        if field_errors:
+            errors_html = render_template_file(
+                "bootstrap4/field_errors.html",
+                context={
+                    "field": self.field,
+                    "field_errors": field_errors,
+                    "layout": self.layout,
+                    "show_help": self.show_help,
+                },
+            )
+            html += errors_html
+        return html
+
     def append_to_field(self, html):
         if isinstance(self.widget, CheckboxInput):
+            # we have already appended errors and help to checkboxes
+            # in append_to_checkbox_field
             return html
-        return self.append_help_and_error(html)
+
+        if not self.is_input_group:
+            # we already appended errors for input groups in make_input_group
+            html = self.append_errors(html)
+
+        return self.append_help(html)
 
     def append_to_checkbox_field(self, html):
         if not isinstance(self.widget, CheckboxInput):
+            # we will append errors and help to normal fields later in append_to_field
             return html
-        return self.append_help_and_error(html)
+
+        html = self.append_errors(html)
+        return self.append_help(html)
 
     def get_field_class(self):
         field_class = self.field_class


Test patch:
diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py
--- a/tests/test_templatetags.py
+++ b/tests/test_templatetags.py
@@ -114,14 +114,20 @@ def render_template_with_bootstrap(text, context=None):
     return render_template("{% load bootstrap4 %}" + text, context)
 
 
-def render_template_with_form(text, context=None):
+def render_template_with_form(text, context=None, data=None):
     """
     Create a template ``text`` that first loads bootstrap4.
+
+    When ``data`` is given, the form will be initialized with data and
+    form.is_valid() will be called in order to enable validations.
     """
     if not context:
         context = {}
     if "form" not in context:
-        context["form"] = TestForm()
+        form = TestForm(data=data)
+        if data:
+            form.is_valid()
+        context["form"] = form
     return render_template_with_bootstrap(text, context)
 
 
@@ -402,6 +408,17 @@ def test_radio_select_button_group(self):
 
 
 class FieldTest(TestCase):
+
+    def _select_one_element(self, html, selector, err_msg):
+        """
+        Select exactly one html element in an BeautifulSoup html fragment.
+
+        Fail if there is not exactly one element.
+        """
+        lst = html.select(selector)
+        self.assertEqual(len(lst), 1, err_msg)
+        return lst[0]
+
     def test_illegal_field(self):
         with self.assertRaises(BootstrapError):
             render_field(field="illegal")
@@ -444,23 +461,19 @@ def test_password(self):
 
     def test_checkbox(self):
         """Test Checkbox rendering, because it is special."""
-
-        def _select_one_element(html, selector, err_msg):
-            lst = html.select(selector)
-            self.assertEqual(len(lst), 1, err_msg)
-            return lst[0]
-
         res = render_form_field("cc_myself")
         # strip out newlines and spaces around newlines
         res = "".join(line.strip() for line in res.split("\n"))
         res = BeautifulSoup(res, "html.parser")
-        form_group = _select_one_element(
+        form_group = self._select_one_element(
             res, ".form-group", "Checkbox should be rendered inside a .form-group."
         )
-        form_check = _select_one_element(
-            form_group, ".form-check", "There should be a .form-check inside .form-group"
+        form_check = self._select_one_element(
+            form_group,
+            ".form-check",
+            "There should be a .form-check inside .form-group",
         )
-        checkbox = _select_one_element(
+        checkbox = self._select_one_element(
             form_check, "input", "The checkbox should be inside the .form-check"
         )
         self.assertIn(
@@ -542,6 +555,43 @@ def test_input_group_addon_empty(self):
             '<div class="input-group-append"><span class="input-group-text">after</span></div>', res
         )
 
+    def test_input_group_addon_validation(self):
+        """
+        Test that invalid-feedback messages are placed inside input-groups.
+
+        See issue #89.
+        """
+        # invalid form data:
+        data = {'subject': ''}
+        res = render_template_with_form(
+            '{% bootstrap_field form.subject addon_before=None addon_after="after" %}',
+            data=data,
+        )  # noqa
+        res = BeautifulSoup(res, "html.parser")
+        self._select_one_element(
+            res,
+            '.input-group > .invalid-feedback',
+            'The invalid-feedback message, complaining that this field is '
+            'required, must be placed inside the input-group',
+        )
+        self._select_one_element(
+            res,
+            '.form-group > .form-text',
+            'The form-text message must be placed inside the form-group',
+        )
+        self.assertEqual(
+            len(res.select('.form-group > .invalid-feedback')),
+            0,
+            'The invalid-feedback message must be placed inside the '
+            'input-group and not inside the form-group',
+        )
+        self.assertEqual(
+            len(res.select('.input-group > .form-text')),
+            0,
+            'The form-text message must be placed inside the form-group and '
+            'not inside the input-group',
+        )
+
     def test_size(self):
         def _test_size(param, klass):
             res = render_template_with_form(