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 @@ -
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" %} -