Repository: cisagov/manage.get.gov

Issue:
Registrar: Add and implement button to "Remove extra contacts"
### Story

As a applicant
I want to be able to remove extra contacts I've added on the [Other employees from your org page](https://getgov-staging.app.cloud.gov/register/other_contacts/)
so that I don't waste time erasing the data I entered into the organization contact fields


### Acceptance Criteria
- [ ] fix add button to match the functionality on nameservers, dnssec and so on
- [ ] implement buttons to "remove" extra contacts on Other Contacts page, [using same pattern](https://github.com/cisagov/getgov/pull/1166) from DNSSEC and Name Servers.
- [ ] Implement look and functionality from this [Figma mockup](https://www.figma.com/file/aJbuDLJNNikqQObTuCNKQa/GetGov_Designs_ECS-Truss?type=design&mode=design). To enter prototype mode click the "present" button at the top right.

### Additional Context

See [Slack Thread](https://cisa-corp.slack.com/archives/C05BDEA3C11/p1697669491409489)


### Issue Links

🔄 Relates to #589 


Discussion:
@PaulKuykendall can we add this to one of the sprints for mvp? This seems very frustrating to not be able to remove someone if you added the wrong person and I wouldn't want it lost in the backlog. CC: @h-m-f-t  
Added it to sprint 35 for now


Resolving patch:
diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py
--- a/src/registrar/forms/application_wizard.py
+++ b/src/registrar/forms/application_wizard.py
@@ -177,7 +177,7 @@ def _to_database(
 
             # no matching database object, create it
             # make sure not to create a database object if cleaned has 'delete' attribute
-            elif db_obj is None and cleaned and not cleaned.get("delete", False):
+            elif db_obj is None and cleaned and not cleaned.get("DELETE", False):
                 kwargs = pre_create(db_obj, cleaned)
                 getattr(obj, join).create(**kwargs)
 
@@ -609,9 +609,12 @@ def __init__(self, *args, **kwargs):
 
         self.fields["has_other_contacts"] = forms.TypedChoiceField(
             coerce=lambda x: x.lower() == "true" if x is not None else None,  # coerce strings to bool, excepting None
-            choices=((True, "Yes, I can name other employees."), (False, "No (We’ll ask you to explain why).")),
+            choices=((True, "Yes, I can name other employees."), (False, "No. (We’ll ask you to explain why.)")),
             initial=initial_value,
             widget=forms.RadioSelect,
+            error_messages={
+                "required": "This question is required.",
+            },
         )
 
 
@@ -638,7 +641,10 @@ class OtherContactsForm(RegistrarForm):
     )
     email = forms.EmailField(
         label="Email",
-        error_messages={"invalid": ("Enter an email address in the required format, like name@example.com.")},
+        error_messages={
+            "required": ("Enter an email address in the required format, like name@example.com."),
+            "invalid": ("Enter an email address in the required format, like name@example.com."),
+        },
     )
     phone = PhoneNumberField(
         label="Phone",
@@ -649,8 +655,17 @@ class OtherContactsForm(RegistrarForm):
     )
 
     def __init__(self, *args, **kwargs):
+        """
+        Override the __init__ method for RegistrarForm.
+        Set form_data_marked_for_deletion to false.
+        Empty_permitted set to False, as this is overridden in certain circumstances by
+        Django's BaseFormSet, and results in empty forms being allowed and field level
+        errors not appropriately raised. This works with code in the view which appropriately
+        displays required attributes on fields.
+        """
         self.form_data_marked_for_deletion = False
         super().__init__(*args, **kwargs)
+        self.empty_permitted = False
 
     def mark_form_for_deletion(self):
         self.form_data_marked_for_deletion = True
@@ -659,12 +674,11 @@ def clean(self):
         """
         This method overrides the default behavior for forms.
         This cleans the form after field validation has already taken place.
-        In this override, allow for a form which is empty to be considered
-        valid even though certain required fields have not passed field
-        validation
+        In this override, allow for a form which is deleted by user or marked for
+        deletion by formset to be considered valid even though certain required fields have
+        not passed field validation
         """
-
-        if self.form_data_marked_for_deletion:
+        if self.form_data_marked_for_deletion or self.cleaned_data.get("DELETE"):
             # clear any errors raised by the form fields
             # (before this clean() method is run, each field
             # performs its own clean, which could result in
@@ -678,12 +692,26 @@ def clean(self):
             # return empty object with only 'delete' attribute defined.
             # this will prevent _to_database from creating an empty
             # database object
-            return {"delete": True}
+            return {"DELETE": True}
 
         return self.cleaned_data
 
 
 class BaseOtherContactsFormSet(RegistrarFormSet):
+    """
+    FormSet for Other Contacts
+
+    There are two conditions by which a form in the formset can be marked for deletion.
+    One is if the user clicks 'DELETE' button, and this is submitted in the form. The
+    other is if the YesNo form, which is submitted with this formset, is set to No; in
+    this case, all forms in formset are marked for deletion. Both of these conditions
+    must co-exist.
+    Also, other_contacts have db relationships to multiple db objects. When attempting
+    to delete an other_contact from an application, those db relationships must be
+    tested and handled; this is configured with REVERSE_JOINS, which is an array of
+    strings representing the relationships between contact model and other models.
+    """
+
     JOIN = "other_contacts"
     REVERSE_JOINS = [
         "user",
@@ -695,7 +723,13 @@ class BaseOtherContactsFormSet(RegistrarFormSet):
         "contact_applications_information",
     ]
 
+    def get_deletion_widget(self):
+        return forms.HiddenInput(attrs={"class": "deletion"})
+
     def __init__(self, *args, **kwargs):
+        """
+        Override __init__ for RegistrarFormSet.
+        """
         self.formset_data_marked_for_deletion = False
         self.application = kwargs.pop("application", None)
         super(RegistrarFormSet, self).__init__(*args, **kwargs)
@@ -706,8 +740,17 @@ def __init__(self, *args, **kwargs):
             self.forms[index].use_required_attribute = True
 
     def should_delete(self, cleaned):
-        empty = (isinstance(v, str) and (v.strip() == "" or v is None) for v in cleaned.values())
-        return all(empty) or self.formset_data_marked_for_deletion
+        """
+        Implements should_delete method from BaseFormSet.
+        """
+        return self.formset_data_marked_for_deletion or cleaned.get("DELETE", False)
+
+    def pre_create(self, db_obj, cleaned):
+        """Code to run before an item in the formset is created in the database."""
+        # remove DELETE from cleaned
+        if "DELETE" in cleaned:
+            cleaned.pop("DELETE")
+        return cleaned
 
     def to_database(self, obj: DomainApplication):
         self._to_database(obj, self.JOIN, self.REVERSE_JOINS, self.should_delete, self.pre_update, self.pre_create)
@@ -736,9 +779,10 @@ def is_valid(self):
 
 OtherContactsFormSet = forms.formset_factory(
     OtherContactsForm,
-    extra=1,
+    extra=0,
     absolute_max=1500,  # django default; use `max_num` to limit entries
     min_num=1,
+    can_delete=True,
     validate_min=True,
     formset=BaseOtherContactsFormSet,
 )
@@ -748,11 +792,7 @@ class NoOtherContactsForm(RegistrarForm):
     no_other_contacts_rationale = forms.CharField(
         required=True,
         # label has to end in a space to get the label_suffix to show
-        label=(
-            "You don’t need to provide names of other employees now, but it may "
-            "slow down our assessment of your eligibility. Describe why there are "
-            "no other employees who can help verify your request."
-        ),
+        label=("No other employees rationale"),
         widget=forms.Textarea(),
         validators=[
             MaxLengthValidator(
diff --git a/src/registrar/views/application.py b/src/registrar/views/application.py
--- a/src/registrar/views/application.py
+++ b/src/registrar/views/application.py
@@ -497,6 +497,13 @@ def is_valid(self, forms: list) -> bool:
         other_contacts_forms = forms[1]
         no_other_contacts_form = forms[2]
 
+        # set all the required other_contact fields as necessary since new forms
+        # were added through javascript
+        for form in forms[1].forms:
+            for field_item, field in form.fields.items():
+                if field.required:
+                    field.widget.attrs["required"] = "required"
+
         all_forms_valid = True
         # test first for yes_no_form validity
         if other_contacts_yes_no_form.is_valid():


Test patch:
diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py
--- a/src/registrar/tests/test_views.py
+++ b/src/registrar/tests/test_views.py
@@ -5,6 +5,7 @@
 from django.test import Client, TestCase
 from django.urls import reverse
 from django.contrib.auth import get_user_model
+
 from .common import MockEppLib, MockSESClient, completed_application, create_user  # type: ignore
 from django_webtest import WebTest  # type: ignore
 import boto3_mocking  # type: ignore
@@ -957,7 +958,7 @@ def test_submitting_no_other_contacts_rationale_deletes_other_contacts(self):
     def test_submitting_no_other_contacts_rationale_removes_reference_other_contacts_when_joined(self):
         """When a user submits the Other Contacts form with no other contacts selected, the application's
         other contacts references get removed for other contacts that exist and are joined to other objects"""
-        # Populate the databse with a domain application that
+        # Populate the database with a domain application that
         # has 1 "other contact" assigned to it
         # We'll do it from scratch so we can reuse the other contact
         ao, _ = Contact.objects.get_or_create(
@@ -1079,31 +1080,115 @@ def test_if_yes_no_form_is_yes_then_other_contacts_required(self):
         # Assert that it is returned, ie the contacts form is required
         self.assertContains(response, "Enter the first name / given name of this contact.")
 
-    @skip("Repurpose when working on ticket 903")
-    def test_application_delete_other_contact(self):
-        """Other contacts can be deleted after being saved to database."""
-        # Populate the databse with a domain application that
+    def test_delete_other_contact(self):
+        """Other contacts can be deleted after being saved to database.
+
+        This formset uses the DJANGO DELETE widget. We'll test that by setting 2 contacts on an application,
+        loading the form and marking one contact up for deletion."""
+        # Populate the database with a domain application that
+        # has 2 "other contact" assigned to it
+        # We'll do it from scratch so we can reuse the other contact
+        ao, _ = Contact.objects.get_or_create(
+            first_name="Testy",
+            last_name="Tester",
+            title="Chief Tester",
+            email="testy@town.com",
+            phone="(201) 555 5555",
+        )
+        you, _ = Contact.objects.get_or_create(
+            first_name="Testy you",
+            last_name="Tester you",
+            title="Admin Tester",
+            email="testy-admin@town.com",
+            phone="(201) 555 5556",
+        )
+        other, _ = Contact.objects.get_or_create(
+            first_name="Testy2",
+            last_name="Tester2",
+            title="Another Tester",
+            email="testy2@town.com",
+            phone="(201) 555 5557",
+        )
+        other2, _ = Contact.objects.get_or_create(
+            first_name="Testy3",
+            last_name="Tester3",
+            title="Another Tester",
+            email="testy3@town.com",
+            phone="(201) 555 5557",
+        )
+        application, _ = DomainApplication.objects.get_or_create(
+            organization_type="federal",
+            federal_type="executive",
+            purpose="Purpose of the site",
+            anything_else="No",
+            is_policy_acknowledged=True,
+            organization_name="Testorg",
+            address_line1="address 1",
+            state_territory="NY",
+            zipcode="10002",
+            authorizing_official=ao,
+            submitter=you,
+            creator=self.user,
+            status="started",
+        )
+        application.other_contacts.add(other)
+        application.other_contacts.add(other2)
+
+        # prime the form by visiting /edit
+        self.app.get(reverse("edit-application", kwargs={"id": application.pk}))
+        # django-webtest does not handle cookie-based sessions well because it keeps
+        # resetting the session key on each new request, thus destroying the concept
+        # of a "session". We are going to do it manually, saving the session ID here
+        # and then setting the cookie on each request.
+        session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+        other_contacts_page = self.app.get(reverse("application:other_contacts"))
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+        other_contacts_form = other_contacts_page.forms[0]
+
+        # Minimal check to ensure the form is loaded with both other contacts
+        self.assertEqual(other_contacts_form["other_contacts-0-first_name"].value, "Testy2")
+        self.assertEqual(other_contacts_form["other_contacts-1-first_name"].value, "Testy3")
+
+        # Mark the first dude for deletion
+        other_contacts_form.set("other_contacts-0-DELETE", "on")
+
+        # Submit the form
+        other_contacts_form.submit()
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+        # Verify that the first dude was deleted
+        application = DomainApplication.objects.get()
+        self.assertEqual(application.other_contacts.count(), 1)
+        self.assertEqual(application.other_contacts.first().first_name, "Testy3")
+
+    def test_delete_other_contact_does_not_allow_zero_contacts(self):
+        """Delete Other Contact does not allow submission with zero contacts."""
+        # Populate the database with a domain application that
         # has 1 "other contact" assigned to it
+        # We'll do it from scratch so we can reuse the other contact
         ao, _ = Contact.objects.get_or_create(
             first_name="Testy",
             last_name="Tester",
             title="Chief Tester",
             email="testy@town.com",
-            phone="(555) 555 5555",
+            phone="(201) 555 5555",
         )
         you, _ = Contact.objects.get_or_create(
             first_name="Testy you",
             last_name="Tester you",
             title="Admin Tester",
             email="testy-admin@town.com",
-            phone="(555) 555 5556",
+            phone="(201) 555 5556",
         )
         other, _ = Contact.objects.get_or_create(
             first_name="Testy2",
             last_name="Tester2",
             title="Another Tester",
             email="testy2@town.com",
-            phone="(555) 555 5557",
+            phone="(201) 555 5557",
         )
         application, _ = DomainApplication.objects.get_or_create(
             organization_type="federal",
@@ -1136,35 +1221,97 @@ def test_application_delete_other_contact(self):
 
         other_contacts_form = other_contacts_page.forms[0]
 
-        # Minimal check to ensure the form is loaded with data (if this part of
-        # the application doesn't work, we should be equipped with other unit
-        # tests to flag it)
+        # Minimal check to ensure the form is loaded
         self.assertEqual(other_contacts_form["other_contacts-0-first_name"].value, "Testy2")
 
-        # clear the form
-        other_contacts_form["other_contacts-0-first_name"] = ""
-        other_contacts_form["other_contacts-0-middle_name"] = ""
-        other_contacts_form["other_contacts-0-last_name"] = ""
-        other_contacts_form["other_contacts-0-title"] = ""
-        other_contacts_form["other_contacts-0-email"] = ""
-        other_contacts_form["other_contacts-0-phone"] = ""
+        # Mark the first dude for deletion
+        other_contacts_form.set("other_contacts-0-DELETE", "on")
 
-        # Submit the now empty form
-        result = other_contacts_form.submit()
+        # Submit the form
+        other_contacts_form.submit()
         self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
 
-        # Verify that the contact we saved earlier has been removed from the database
-        application = DomainApplication.objects.get()  # There are no contacts anymore
-        self.assertEqual(
-            application.other_contacts.count(),
-            0,
+        # Verify that the contact was not deleted
+        application = DomainApplication.objects.get()
+        self.assertEqual(application.other_contacts.count(), 1)
+        self.assertEqual(application.other_contacts.first().first_name, "Testy2")
+
+    def test_delete_other_contact_sets_visible_empty_form_as_required_after_failed_submit(self):
+        """When you:
+            1. add an empty contact,
+            2. delete existing contacts,
+            3. then submit,
+        The forms on page reload shows all the required fields and their errors."""
+
+        # Populate the database with a domain application that
+        # has 1 "other contact" assigned to it
+        # We'll do it from scratch so we can reuse the other contact
+        ao, _ = Contact.objects.get_or_create(
+            first_name="Testy",
+            last_name="Tester",
+            title="Chief Tester",
+            email="testy@town.com",
+            phone="(201) 555 5555",
+        )
+        you, _ = Contact.objects.get_or_create(
+            first_name="Testy you",
+            last_name="Tester you",
+            title="Admin Tester",
+            email="testy-admin@town.com",
+            phone="(201) 555 5556",
+        )
+        other, _ = Contact.objects.get_or_create(
+            first_name="Testy2",
+            last_name="Tester2",
+            title="Another Tester",
+            email="testy2@town.com",
+            phone="(201) 555 5557",
+        )
+        application, _ = DomainApplication.objects.get_or_create(
+            organization_type="federal",
+            federal_type="executive",
+            purpose="Purpose of the site",
+            anything_else="No",
+            is_policy_acknowledged=True,
+            organization_name="Testorg",
+            address_line1="address 1",
+            state_territory="NY",
+            zipcode="10002",
+            authorizing_official=ao,
+            submitter=you,
+            creator=self.user,
+            status="started",
         )
+        application.other_contacts.add(other)
+
+        # prime the form by visiting /edit
+        self.app.get(reverse("edit-application", kwargs={"id": application.pk}))
+        # django-webtest does not handle cookie-based sessions well because it keeps
+        # resetting the session key on each new request, thus destroying the concept
+        # of a "session". We are going to do it manually, saving the session ID here
+        # and then setting the cookie on each request.
+        session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+        other_contacts_page = self.app.get(reverse("application:other_contacts"))
+        self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
+
+        other_contacts_form = other_contacts_page.forms[0]
+
+        # Minimal check to ensure the form is loaded
+        self.assertEqual(other_contacts_form["other_contacts-0-first_name"].value, "Testy2")
+
+        # Set total forms to 2 indicating an additional formset was added.
+        # Submit no data though for the second formset.
+        # Set the first formset to be deleted.
+        other_contacts_form["other_contacts-TOTAL_FORMS"] = "2"
+        other_contacts_form.set("other_contacts-0-DELETE", "on")
 
-        # Verify that on submit, user is advanced to "no contacts" page
-        no_contacts_page = result.follow()
-        expected_url_slug = str(Step.NO_OTHER_CONTACTS)
-        actual_url_slug = no_contacts_page.request.path.split("/")[-2]
-        self.assertEqual(expected_url_slug, actual_url_slug)
+        response = other_contacts_form.submit()
+
+        # Assert that the response presents errors to the user, including to
+        # Enter the first name ...
+        self.assertContains(response, "Enter the first name / given name of this contact.")
 
     def test_application_about_your_organiztion_interstate(self):
         """Special districts have to answer an additional question."""