diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 11ba49aa9..68e8af69c 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -229,99 +229,203 @@ function handleValidationClick(e) { } })(); +/** + * Delete method for formsets that diff in the view and delete in the model (Nameservers, DS Data) + * + */ +function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){ + let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); + let formToRemove = e.target.closest(".repeatable-form"); + formToRemove.remove(); + let forms = document.querySelectorAll(".repeatable-form"); + totalForms.setAttribute('value', `${forms.length}`); + + let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g'); + let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g'); + // For the example on Nameservers + let formExampleRegex = RegExp(`ns(\\d+){1}`, 'g'); + + forms.forEach((form, index) => { + // Iterate over child nodes of the current element + Array.from(form.querySelectorAll('label, input, select')).forEach((node) => { + // Iterate through the attributes of the current node + Array.from(node.attributes).forEach((attr) => { + // Check if the attribute value matches the regex + if (formNumberRegex.test(attr.value)) { + // Replace the attribute value with the updated value + attr.value = attr.value.replace(formNumberRegex, `form-${index}-`); + } + }); + }); + + // h2 and legend for DS form, label for nameservers + Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => { + + // If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required) + // inject the USWDS required markup and make sure the INPUT is required + if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) { + // Create a new element + const newElement = document.createElement('abbr'); + newElement.textContent = '*'; + newElement.setAttribute("title", "required"); + newElement.classList.add("usa-hint", "usa-hint--required"); + + // Append the new element to the label + node.appendChild(newElement); + // Find the next sibling that is an input element + let nextInputElement = node.nextElementSibling; + + while (nextInputElement) { + if (nextInputElement.tagName === 'INPUT') { + // Found the next input element + nextInputElement.setAttribute("required", "") + break; + } + nextInputElement = nextInputElement.nextElementSibling; + } + nextInputElement.required = true; + } + + let innerSpan = node.querySelector('span') + if (innerSpan) { + innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); + } else { + node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); + node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`); + } + }); + + // Display the add more button if we have less than 13 forms + if (isNameserversForm && forms.length <= 13) { + addButton.removeAttribute("disabled"); + } + + if (isNameserversForm && forms.length < 3) { + // Hide the delete buttons on the remaining nameservers + Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { + deleteButton.setAttribute("disabled", "true"); + }); + } + + }); +} /** - * Prepare the namerservers and DS data forms delete buttons - * We will call this on the forms init, and also every time we add a form + * Delete method for formsets using the DJANGO DELETE widget (Other Contacts) + * + */ +function markForm(e, formLabel){ + // Unlike removeForm, we only work with the visible forms when using DJANGO's DELETE widget + let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length; + + if (totalShownForms == 1) { + // toggle the radio buttons + let radioButton = document.querySelector('input[name="other_contacts-has_other_contacts"][value="False"]'); + radioButton.checked = true; + // Trigger the change event + let event = new Event('change'); + radioButton.dispatchEvent(event); + } else { + + // Grab the hidden delete input and assign a value DJANGO will look for + let formToRemove = e.target.closest(".repeatable-form"); + if (formToRemove) { + let deleteInput = formToRemove.querySelector('input[class="deletion"]'); + if (deleteInput) { + deleteInput.value = 'on'; + } + } + + // Set display to 'none' + formToRemove.style.display = 'none'; + } + + // Update h2s on the visible forms only. We won't worry about the forms' identifiers + let shownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`); + let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g'); + shownForms.forEach((form, index) => { + // Iterate over child nodes of the current element + Array.from(form.querySelectorAll('h2')).forEach((node) => { + node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); + }); + }); +} + +/** + * Prepare the namerservers, DS data and Other Contacts formsets' delete button + * for the last added form. We call this from the Add function + * + */ +function prepareNewDeleteButton(btn, formLabel) { + let formIdentifier = "form" + let isNameserversForm = document.querySelector(".nameservers-form"); + let isOtherContactsForm = document.querySelector(".other-contacts-form"); + let addButton = document.querySelector("#add-form"); + + if (isOtherContactsForm) { + formIdentifier = "other_contacts"; + // We will mark the forms for deletion + btn.addEventListener('click', function(e) { + markForm(e, formLabel); + }); + } else { + // We will remove the forms and re-order the formset + btn.addEventListener('click', function(e) { + removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); + }); + } +} + +/** + * Prepare the namerservers, DS data and Other Contacts formsets' delete buttons + * We will call this on the forms init * */ function prepareDeleteButtons(formLabel) { + let formIdentifier = "form" let deleteButtons = document.querySelectorAll(".delete-record"); - let totalForms = document.querySelector("#id_form-TOTAL_FORMS"); - let isNameserversForm = document.title.includes("DNS name servers |"); + let isNameserversForm = document.querySelector(".nameservers-form"); + let isOtherContactsForm = document.querySelector(".other-contacts-form"); let addButton = document.querySelector("#add-form"); - + if (isOtherContactsForm) { + formIdentifier = "other_contacts"; + } + // Loop through each delete button and attach the click event listener deleteButtons.forEach((deleteButton) => { - deleteButton.addEventListener('click', removeForm); + if (isOtherContactsForm) { + // We will mark the forms for deletion + deleteButton.addEventListener('click', function(e) { + markForm(e, formLabel); + }); + } else { + // We will remove the forms and re-order the formset + deleteButton.addEventListener('click', function(e) { + removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); + }); + } }); +} - function removeForm(e){ - let formToRemove = e.target.closest(".repeatable-form"); - formToRemove.remove(); - let forms = document.querySelectorAll(".repeatable-form"); - totalForms.setAttribute('value', `${forms.length}`); +/** + * DJANGO formset's DELETE widget + * On form load, hide deleted forms, ie. those forms with hidden input of class 'deletion' + * with value='on' + */ +function hideDeletedForms() { + let hiddenDeleteButtonsWithValueOn = document.querySelectorAll('input[type="hidden"].deletion[value="on"]'); - let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g'); - let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g'); - // For the example on Nameservers - let formExampleRegex = RegExp(`ns(\\d+){1}`, 'g'); - - forms.forEach((form, index) => { - // Iterate over child nodes of the current element - Array.from(form.querySelectorAll('label, input, select')).forEach((node) => { - // Iterate through the attributes of the current node - Array.from(node.attributes).forEach((attr) => { - // Check if the attribute value matches the regex - if (formNumberRegex.test(attr.value)) { - // Replace the attribute value with the updated value - attr.value = attr.value.replace(formNumberRegex, `form-${index}-`); - } - }); - }); - - // h2 and legend for DS form, label for nameservers - Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => { - - // If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required) - // inject the USWDS required markup and make sure the INPUT is required - if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) { - // Create a new element - const newElement = document.createElement('abbr'); - newElement.textContent = '*'; - newElement.setAttribute("title", "required"); - newElement.classList.add("usa-hint", "usa-hint--required"); - - // Append the new element to the label - node.appendChild(newElement); - // Find the next sibling that is an input element - let nextInputElement = node.nextElementSibling; - - while (nextInputElement) { - if (nextInputElement.tagName === 'INPUT') { - // Found the next input element - nextInputElement.setAttribute("required", "") - break; - } - nextInputElement = nextInputElement.nextElementSibling; - } - nextInputElement.required = true; - } - - let innerSpan = node.querySelector('span') - if (innerSpan) { - innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); - } else { - node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); - node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`); - } - }); - - // Display the add more button if we have less than 13 forms - if (isNameserversForm && forms.length <= 13) { - console.log('remove disabled'); - addButton.removeAttribute("disabled"); + // Iterating over the NodeList of hidden inputs + hiddenDeleteButtonsWithValueOn.forEach(function(hiddenInput) { + // Finding the closest parent element with class "repeatable-form" for each hidden input + var repeatableFormToHide = hiddenInput.closest('.repeatable-form'); + + // Checking if a matching parent element is found for each hidden input + if (repeatableFormToHide) { + // Setting the display property to "none" for each matching parent element + repeatableFormToHide.style.display = 'none'; } - - if (isNameserversForm && forms.length < 3) { - // Hide the delete buttons on the remaining nameservers - Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { - deleteButton.setAttribute("disabled", "true"); - }); - } - - }); - } + }); } /** @@ -331,25 +435,38 @@ function prepareDeleteButtons(formLabel) { * it everywhere. */ (function prepareFormsetsForms() { + let formIdentifier = "form" let repeatableForm = document.querySelectorAll(".repeatable-form"); let container = document.querySelector("#form-container"); let addButton = document.querySelector("#add-form"); - let totalForms = document.querySelector("#id_form-TOTAL_FORMS"); let cloneIndex = 0; let formLabel = ''; - let isNameserversForm = document.title.includes("DNS name servers |"); + let isNameserversForm = document.querySelector(".nameservers-form"); + let isOtherContactsForm = document.querySelector(".other-contacts-form"); + let isDsDataForm = document.querySelector(".ds-data-form"); + // The Nameservers formset features 2 required and 11 optionals if (isNameserversForm) { cloneIndex = 2; formLabel = "Name server"; - } else if ((document.title.includes("DS Data |")) || (document.title.includes("Key Data |"))) { - formLabel = "DS Data record"; + // DNSSEC: DS Data + } else if (isDsDataForm) { + formLabel = "DS data record"; + // The Other Contacts form + } else if (isOtherContactsForm) { + formLabel = "Organization contact"; + container = document.querySelector("#other-employees"); + formIdentifier = "other_contacts" } + let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); // On load: Disable the add more button if we have 13 forms if (isNameserversForm && document.querySelectorAll(".repeatable-form").length == 13) { addButton.setAttribute("disabled", "true"); } + // Hide forms which have previously been deleted + hideDeletedForms() + // Attach click event listener on the delete buttons of the existing forms prepareDeleteButtons(formLabel); @@ -360,7 +477,7 @@ function prepareDeleteButtons(formLabel) { let forms = document.querySelectorAll(".repeatable-form"); let formNum = forms.length; let newForm = repeatableForm[cloneIndex].cloneNode(true); - let formNumberRegex = RegExp(`form-(\\d){1}-`,'g'); + let formNumberRegex = RegExp(`${formIdentifier}-(\\d){1}-`,'g'); let formLabelRegex = RegExp(`${formLabel} (\\d){1}`, 'g'); // For the eample on Nameservers let formExampleRegex = RegExp(`ns(\\d){1}`, 'g'); @@ -393,16 +510,27 @@ function prepareDeleteButtons(formLabel) { } formNum++; - newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `form-${formNum-1}-`); - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); + + newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `${formIdentifier}-${formNum-1}-`); + // For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden, + // since the form on the backend employs Django's DELETE widget. For the other formsets, we delete the form + // in JS (completely remove from teh DOM) so we update the headers/labels based on total number of forms. + if (isOtherContactsForm) { + let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length; + newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`); + } else { + newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); + } newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`); container.insertBefore(newForm, addButton); + newForm.style.display = 'block'; + let inputs = newForm.querySelectorAll("input"); // Reset the values of each input to blank inputs.forEach((input) => { input.classList.remove("usa-input--error"); - if (input.type === "text" || input.type === "number" || input.type === "password") { + if (input.type === "text" || input.type === "number" || input.type === "password" || input.type === "email" || input.type === "tel") { input.value = ""; // Set the value to an empty string } else if (input.type === "checkbox" || input.type === "radio") { @@ -439,7 +567,8 @@ function prepareDeleteButtons(formLabel) { totalForms.setAttribute('value', `${formNum}`); // Attach click event listener on the delete buttons of the new form - prepareDeleteButtons(formLabel); + let newDeleteButton = newForm.querySelector(".delete-record"); + prepareNewDeleteButton(newDeleteButton, formLabel); // Disable the add more button if we have 13 forms if (isNameserversForm && formNum == 13) { @@ -484,6 +613,7 @@ function prepareDeleteButtons(formLabel) { } })(); +// A generic display none/block toggle function that takes an integer param to indicate how the elements toggle function toggleTwoDomElements(ele1, ele2, index) { let element1 = document.getElementById(ele1); let element2 = document.getElementById(ele2); diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index 1d936a255..b6d13cee3 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -4,6 +4,10 @@ .sr-only { @include sr-only; } + +.clear-both { + clear: both; +} * { -webkit-font-smoothing: antialiased; diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index d0bfbee67..94407f88d 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -31,3 +31,10 @@ padding-left: 0; border-left: none; } + +legend.float-left-tablet + button.float-right-tablet { + margin-top: .5rem; + @include at-media('tablet') { + margin-top: 1rem; + } +} diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index 8cae4f15f..af52f0c12 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -177,7 +177,7 @@ class RegistrarFormSet(forms.BaseFormSet): # 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 @@ class OtherContactsYesNoForm(RegistrarForm): 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 @@ class OtherContactsForm(RegistrarForm): """ 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 @@ class OtherContactsForm(RegistrarForm): # 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 @@ class BaseOtherContactsFormSet(RegistrarFormSet): 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 @@ class BaseOtherContactsFormSet(RegistrarFormSet): 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/templates/application_other_contacts.html b/src/registrar/templates/application_other_contacts.html index 0c2f59768..c8810edce 100644 --- a/src/registrar/templates/application_other_contacts.html +++ b/src/registrar/templates/application_other_contacts.html @@ -29,17 +29,31 @@ -
+
{% include "includes/required_fields.html" %} {{ forms.1.management_form }} {# forms.1 is a formset and this iterates over its forms #} {% for form in forms.1.forms %} -
- -

Organization contact {{ forloop.counter }} (optional)

-
+
+ + +

Organization contact {{ forloop.counter }}

+
- {% input_with_errors form.first_name %} + + + + {% if forms.1.can_delete %} + {{ form.DELETE }} + {% endif %} + +
+ {% input_with_errors form.first_name %} +
{% input_with_errors form.middle_name %} @@ -52,17 +66,17 @@ affecting the margin of this block. The wrapper div is a temporary workaround. {% endcomment %}
- {% input_with_errors form.email %} + {% input_with_errors form.email %}
{% with add_class="usa-input--medium" %} - {% input_with_errors form.phone %} + {% input_with_errors form.phone %} {% endwith %}
{% endfor %} -
-
+
-

No other employees from your organization?

+

No other employees from your organization?

+

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.

{% with attr_maxlength=1000 add_label_class="usa-sr-only" %} {% input_with_errors forms.2.no_other_contacts_rationale %} {% endwith %} diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index 1ec4c1f93..b62ad7ec5 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -24,7 +24,7 @@ {% include "includes/required_fields.html" %} -
+ {% csrf_token %} {{ formset.management_form }} diff --git a/src/registrar/templates/domain_nameservers.html b/src/registrar/templates/domain_nameservers.html index 15b810193..d60be2de8 100644 --- a/src/registrar/templates/domain_nameservers.html +++ b/src/registrar/templates/domain_nameservers.html @@ -24,7 +24,7 @@ {% include "includes/required_fields.html" %} - + {% csrf_token %} {{ formset.management_form }} diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 4de5b9e78..f3e1325e2 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -5,6 +5,7 @@ from django.conf import settings 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 @@ -950,7 +951,7 @@ class DomainApplicationTests(TestWithUser, WebTest): 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( @@ -1072,31 +1073,115 @@ class DomainApplicationTests(TestWithUser, WebTest): # 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 - # has 1 "other contact" assigned to it + 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="(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", + ) + 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="(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", @@ -1129,35 +1214,97 @@ class DomainApplicationTests(TestWithUser, WebTest): 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") - # 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) + 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") + + 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.""" diff --git a/src/registrar/views/application.py b/src/registrar/views/application.py index 486964e66..83fc3acbb 100644 --- a/src/registrar/views/application.py +++ b/src/registrar/views/application.py @@ -498,6 +498,13 @@ class OtherContacts(ApplicationWizard): 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():