diff --git a/src/registrar/assets/src/js/getgov/domain-dnssec.js b/src/registrar/assets/src/js/getgov/domain-dnssec.js index 860359fe0..0d6ae4970 100644 --- a/src/registrar/assets/src/js/getgov/domain-dnssec.js +++ b/src/registrar/assets/src/js/getgov/domain-dnssec.js @@ -1,4 +1,4 @@ -import { submitForm } from './helpers.js'; +import { submitForm } from './form-helpers.js'; export function initDomainDNSSEC() { document.addEventListener('DOMContentLoaded', function() { diff --git a/src/registrar/assets/src/js/getgov/domain-dsdata.js b/src/registrar/assets/src/js/getgov/domain-dsdata.js index 7c0871bec..14132d812 100644 --- a/src/registrar/assets/src/js/getgov/domain-dsdata.js +++ b/src/registrar/assets/src/js/getgov/domain-dsdata.js @@ -1,4 +1,4 @@ -import { submitForm } from './helpers.js'; +import { submitForm } from './form-helpers.js'; export function initDomainDSData() { document.addEventListener('DOMContentLoaded', function() { diff --git a/src/registrar/assets/src/js/getgov/domain-managers.js b/src/registrar/assets/src/js/getgov/domain-managers.js index 26eccd8cd..3d77b7cca 100644 --- a/src/registrar/assets/src/js/getgov/domain-managers.js +++ b/src/registrar/assets/src/js/getgov/domain-managers.js @@ -1,4 +1,4 @@ -import { submitForm } from './helpers.js'; +import { submitForm } from './form-helpers.js'; export function initDomainManagersPage() { document.addEventListener('DOMContentLoaded', function() { diff --git a/src/registrar/assets/src/js/getgov/domain-request-form.js b/src/registrar/assets/src/js/getgov/domain-request-form.js index b49912fa4..73bb6accd 100644 --- a/src/registrar/assets/src/js/getgov/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov/domain-request-form.js @@ -1,4 +1,4 @@ -import { submitForm } from './helpers.js'; +import { submitForm } from './form-helpers.js'; export function initDomainRequestForm() { document.addEventListener('DOMContentLoaded', function() { diff --git a/src/registrar/assets/src/js/getgov/form-helpers.js b/src/registrar/assets/src/js/getgov/form-helpers.js new file mode 100644 index 000000000..fabfab98a --- /dev/null +++ b/src/registrar/assets/src/js/getgov/form-helpers.js @@ -0,0 +1,57 @@ +/** + * Helper function to submit a form + * @param {} form_id - the id of the form to be submitted + */ +export function submitForm(form_id) { + let form = document.getElementById(form_id); + if (form) { + form.submit(); + } else { + console.error("Form '" + form_id + "' not found."); + } +} + + +/** + * Removes all error-related classes and messages from the specified DOM element. + * This method cleans up validation errors by removing error highlighting from input fields, + * labels, and form groups, as well as deleting error message elements. + * @param {HTMLElement} domElement - The parent element within which errors should be cleared. + */ +export function removeErrorsFromElement(domElement) { + // Remove the 'usa-form-group--error' class from all div elements + domElement.querySelectorAll("div.usa-form-group--error").forEach(div => { + div.classList.remove("usa-form-group--error"); + }); + + // Remove the 'usa-label--error' class from all label elements + domElement.querySelectorAll("label.usa-label--error").forEach(label => { + label.classList.remove("usa-label--error"); + }); + + // Remove all error message divs whose ID ends with '__error-message' + domElement.querySelectorAll("div[id$='__error-message']").forEach(errorDiv => { + errorDiv.remove(); + }); + + // Remove the 'usa-input--error' class from all input elements + domElement.querySelectorAll("input.usa-input--error").forEach(input => { + input.classList.remove("usa-input--error"); + }); +} + +/** + * Removes all form-level error messages displayed in the UI. + * The form error messages are contained within div elements with the ID 'form-errors'. + * Since multiple elements with the same ID may exist (even though not syntactically correct), + * this function removes them iteratively. + */ +export function removeFormErrors() { + let formErrorDiv = document.getElementById("form-errors"); + + // Recursively remove all instances of form error divs + while (formErrorDiv) { + formErrorDiv.remove(); + formErrorDiv = document.getElementById("form-errors"); + } +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/form-nameservers.js b/src/registrar/assets/src/js/getgov/form-nameservers.js new file mode 100644 index 000000000..57b868d70 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/form-nameservers.js @@ -0,0 +1,516 @@ +import { showElement, hideElement, scrollToElement } from './helpers'; +import { removeErrorsFromElement, removeFormErrors } from './form-helpers'; + +export class NameserverForm { + constructor() { + this.addNameserverButton = document.getElementById('nameserver-add-button'); + this.addNameserversForm = document.querySelector('.add-nameservers-form'); + this.domain = ''; + this.formChanged = false; + this.callback = null; + + // Bind event handlers to maintain 'this' context + this.handleAddFormClick = this.handleAddFormClick.bind(this); + this.handleEditClick = this.handleEditClick.bind(this); + this.handleDeleteClick = this.handleDeleteClick.bind(this); + this.handleDeleteKebabClick = this.handleDeleteKebabClick.bind(this); + this.handleCancelClick = this.handleCancelClick.bind(this); + this.handleCancelAddFormClick = this.handleCancelAddFormClick.bind(this); + } + + /** + * Initialize the NameserverForm by setting up display and event listeners. + */ + init() { + this.initializeNameserverFormDisplay(); + this.initializeEventListeners(); + } + + + /** + * Determines the initial display state of the nameserver form, + * handling validation errors and setting visibility of elements accordingly. + */ + initializeNameserverFormDisplay() { + + const domainName = document.getElementById('id_form-0-domain'); + if (domainName) { + this.domain = domainName.value; + } else { + console.warn("Form expects a dom element, id_form-0-domain"); + } + + // Check if exactly two nameserver forms exist: id_form-1-server is present but id_form-2-server is not + const secondNameserver = document.getElementById('id_form-1-server'); + const thirdNameserver = document.getElementById('id_form-2-server'); // This should not exist + + // Check if there are error messages in the form (indicated by elements with class 'usa-alert--error') + const errorMessages = document.querySelectorAll('.usa-alert--error'); + + // This check indicates that there are exactly two forms (which is the case for the Add New Nameservers form) + // and there is at least one error in the form. In this case, show the Add New Nameservers form, and + // indicate that the form has changed + if (this.addNameserversForm && secondNameserver && !thirdNameserver && errorMessages.length > 0) { + showElement(this.addNameserversForm); + this.formChanged = true; + } + + // This check indicates that there is either an Add New Nameservers form or an Add New Nameserver form + // and that form has errors in it. In this case, show the form, and indicate that the form has + // changed. + if (this.addNameserversForm && this.addNameserversForm.querySelector('.usa-input--error')) { + showElement(this.addNameserversForm); + this.formChanged = true; + } + + // handle display of table view errors + // if error exists in an edit-row, make that row show, and readonly row hide + const formTable = document.getElementById('nameserver-table') + if (formTable) { + const editRows = formTable.querySelectorAll('.edit-row'); + editRows.forEach(editRow => { + if (editRow.querySelector('.usa-input--error')) { + const readOnlyRow = editRow.previousElementSibling; + this.formChanged = true; + showElement(editRow); + hideElement(readOnlyRow); + } + }) + } + + // hide ip in forms unless nameserver ends with domain name + let formIndex = 0; + while (document.getElementById('id_form-' + formIndex + '-domain')) { + let serverInput = document.getElementById('id_form-' + formIndex + '-server'); + let ipInput = document.getElementById('id_form-' + formIndex + '-ip'); + if (serverInput && ipInput) { + let serverValue = serverInput.value.trim(); // Get the value and trim spaces + let ipParent = ipInput.parentElement; // Get the parent element of ipInput + + if (ipParent && !serverValue.endsWith('.' + this.domain)) { + hideElement(ipParent); // Hide the parent element of ipInput + } + } + formIndex++; + } + } + + /** + * Attaches event listeners to relevant UI elements for interaction handling. + */ + initializeEventListeners() { + this.addNameserverButton.addEventListener('click', this.handleAddFormClick); + + const editButtons = document.querySelectorAll('.nameserver-edit'); + editButtons.forEach(editButton => { + editButton.addEventListener('click', this.handleEditClick); + }); + + const cancelButtons = document.querySelectorAll('.nameserver-cancel'); + cancelButtons.forEach(cancelButton => { + cancelButton.addEventListener('click', this.handleCancelClick); + }); + + const cancelAddFormButtons = document.querySelectorAll('.nameserver-cancel-add-form'); + cancelAddFormButtons.forEach(cancelAddFormButton => { + cancelAddFormButton.addEventListener('click', this.handleCancelAddFormClick); + }); + + const deleteButtons = document.querySelectorAll('.nameserver-delete'); + deleteButtons.forEach(deleteButton => { + deleteButton.addEventListener('click', this.handleDeleteClick); + }); + + const deleteKebabButtons = document.querySelectorAll('.nameserver-delete-kebab'); + deleteKebabButtons.forEach(deleteKebabButton => { + deleteKebabButton.addEventListener('click', this.handleDeleteKebabClick); + }); + + const textInputs = document.querySelectorAll("input[type='text']"); + textInputs.forEach(input => { + input.addEventListener("input", () => { + this.formChanged = true; + }); + }); + + // Add a specific listener for 'id_form-{number}-server' inputs to make + // nameserver forms 'smart'. Inputs on server field will change the + // display value of the associated IP address field. + let formIndex = 0; + while (document.getElementById(`id_form-${formIndex}-server`)) { + let serverInput = document.getElementById(`id_form-${formIndex}-server`); + let ipInput = document.getElementById(`id_form-${formIndex}-ip`); + if (serverInput && ipInput) { + let ipParent = ipInput.parentElement; // Get the parent element of ipInput + let ipTd = ipParent.parentElement; + // add an event listener on the server input that adjusts visibility + // and value of the ip input (and its parent) + serverInput.addEventListener("input", () => { + let serverValue = serverInput.value.trim(); + if (ipParent && ipTd) { + if (serverValue.endsWith('.' + this.domain)) { + showElement(ipParent); // Show IP field if the condition matches + ipTd.classList.add('width-40p'); + } else { + hideElement(ipParent); // Hide IP field otherwise + ipTd.classList.remove('width-40p'); + ipInput.value = ""; // Set the IP value to blank + } + } else { + console.warn("Expected DOM element but did not find it"); + } + }); + } + formIndex++; // Move to the next index + } + + // Set event listeners on the submit buttons for the modals. Event listeners + // should execute the callback function, which has its logic updated prior + // to modal display + const unsaved_changes_modal = document.getElementById('unsaved-changes-modal'); + if (unsaved_changes_modal) { + const submitButton = document.getElementById('unsaved-changes-click-button'); + const closeButton = unsaved_changes_modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + this.executeCallback(); + }); + } + const delete_modal = document.getElementById('delete-modal'); + if (delete_modal) { + const submitButton = document.getElementById('delete-click-button'); + const closeButton = delete_modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + this.executeCallback(); + }); + } + + } + + /** + * Executes a stored callback function if defined, otherwise logs a warning. + */ + executeCallback() { + if (this.callback) { + this.callback(); + this.callback = null; + } else { + console.warn("No callback function set."); + } + } + + /** + * Handles clicking the 'Add Nameserver' button, showing the form if needed. + * @param {Event} event - Click event + */ + handleAddFormClick(event) { + this.callback = () => { + // Check if any other edit row is currently visible and hide it + document.querySelectorAll('tr.edit-row:not(.display-none)').forEach(openEditRow => { + this.resetEditRowAndFormAndCollapseEditRow(openEditRow); + }); + if (this.addNameserversForm) { + // Check if this.addNameserversForm is visible (i.e., does not have 'display-none') + if (!this.addNameserversForm.classList.contains('display-none')) { + this.resetAddNameserversForm(); + } + // show nameservers form + showElement(this.addNameserversForm); + } else { + this.addAlert("error", "You’ve reached the maximum amount of name server records (13). To add another record, you’ll need to delete one of your saved records."); + } + }; + if (this.formChanged) { + //------- Show the unsaved changes confirmation modal + let modalTrigger = document.querySelector("#unsaved_changes_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } + } else { + this.executeCallback(); + } + } + + /** + * Handles clicking an 'Edit' button on a readonly row, which hides the readonly row + * and displays the edit row, after performing some checks and possibly displaying modal. + * @param {Event} event - Click event + */ + handleEditClick(event) { + let editButton = event.target; + let readOnlyRow = editButton.closest('tr'); // Find the closest row + let editRow = readOnlyRow.nextElementSibling; // Get the next row + if (!editRow || !readOnlyRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + this.callback = () => { + // Check if any other edit row is currently visible and hide it + document.querySelectorAll('tr.edit-row:not(.display-none)').forEach(openEditRow => { + this.resetEditRowAndFormAndCollapseEditRow(openEditRow); + }); + // Check if this.addNameserversForm is visible (i.e., does not have 'display-none') + if (this.addNameserversForm && !this.addNameserversForm.classList.contains('display-none')) { + this.resetAddNameserversForm(); + } + // hide and show rows as appropriate + hideElement(readOnlyRow); + showElement(editRow); + }; + if (this.formChanged) { + //------- Show the unsaved changes confirmation modal + let modalTrigger = document.querySelector("#unsaved_changes_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } + } else { + this.executeCallback(); + } + } + + /** + * Handles clicking a 'Delete' button on an edit row, which hattempts to delete the nameserver + * after displaying modal and performing check for minimum number of nameservers. + * @param {Event} event - Click event + */ + handleDeleteClick(event) { + let deleteButton = event.target; + let editRow = deleteButton.closest('tr'); + if (!editRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + this.deleteRow(editRow); + } + + /** + * Handles clicking a 'Delete' button on a readonly row in a kebab, which attempts to delete the nameserver + * after displaying modal and performing check for minimum number of nameservers. + * @param {Event} event - Click event + */ + handleDeleteKebabClick(event) { + let deleteKebabButton = event.target; + let accordionDiv = deleteKebabButton.closest('div'); + // hide the accordion + accordionDiv.hidden = true; + let readOnlyRow = deleteKebabButton.closest('tr'); // Find the closest row + let editRow = readOnlyRow.nextElementSibling; // Get the next row + if (!editRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + this.deleteRow(editRow); + } + + /** + * Deletes a nameserver row after verifying the minimum required nameservers exist. + * If there are only two nameservers left, deletion is prevented, and an alert is shown. + * If deletion proceeds, the input fields are cleared, and the form is submitted. + * @param {HTMLElement} editRow - The row corresponding to the nameserver being deleted. + */ + deleteRow(editRow) { + // Check if at least two nameserver forms exist + const fourthNameserver = document.getElementById('id_form-3-server'); // This should exist + // This checks that at least 3 nameservers exist prior to the delete of a row, and if not + // display an error alert + if (fourthNameserver) { + this.callback = () => { + hideElement(editRow); + let textInputs = editRow.querySelectorAll("input[type='text']"); + textInputs.forEach(input => { + input.value = ""; + }); + document.querySelector("form").submit(); + }; + let modalTrigger = document.querySelector('#delete_trigger'); + if (modalTrigger) { + modalTrigger.click(); + } + } else { + this.addAlert("error", "At least two name servers are required. To proceed, add a new name server before removing this name server. If you need help, email us at help@get.gov."); + } + } + + /** + * Handles the click event on the "Cancel" button in the add nameserver form. + * Resets the form fields and hides the add form section. + * @param {Event} event - Click event + */ + handleCancelAddFormClick(event) { + this.resetAddNameserversForm(); + } + + /** + * Handles the click event for the cancel button within the table form. + * + * This method identifies the edit row containing the cancel button and resets + * it to its initial state, restoring the corresponding read-only row. + * + * @param {Event} event - the click event triggered by the cancel button + */ + handleCancelClick(event) { + // get the cancel button that was clicked + let cancelButton = event.target; + // find the closest table row that contains the cancel button + let editRow = cancelButton.closest('tr'); + if (editRow) { + this.resetEditRowAndFormAndCollapseEditRow(editRow); + } else { + console.warn("Expected DOM element but did not find it"); + } + } + + /** + * Resets the edit row, restores its original values, removes validation errors, + * and collapses the edit row while making the readonly row visible again. + * @param {HTMLElement} editRow - The row that is being reset and collapsed. + */ + resetEditRowAndFormAndCollapseEditRow(editRow) { + let readOnlyRow = editRow.previousElementSibling; // Get the next row + if (!editRow || !readOnlyRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + // reset the values set in editRow + this.resetInputValuesInElement(editRow); + // copy values from editRow to readOnlyRow + this.copyEditRowToReadonlyRow(editRow, readOnlyRow); + // remove errors from the editRow + removeErrorsFromElement(editRow); + // remove errors from the entire form + removeFormErrors(); + // reset formChanged + this.resetFormChanged(); + // hide and show rows as appropriate + hideElement(editRow); + showElement(readOnlyRow); + } + + /** + * Resets the 'Add Nameserver' form by clearing its input fields, removing errors, + * and hiding the form to return it to its initial state. + */ + resetAddNameserversForm() { + if (this.addNameserversForm) { + // reset the values set in addNameserversForm + this.resetInputValuesInElement(this.addNameserversForm); + // remove errors from the addNameserversForm + removeErrorsFromElement(this.addNameserversForm); + // remove errors from the entire form + removeFormErrors(); + // reset formChanged + this.resetFormChanged(); + // hide the addNameserversForm + hideElement(this.addNameserversForm); + } + } + + /** + * Resets all text input fields within the specified DOM element to their initial values. + * Triggers an 'input' event to ensure any event listeners update accordingly. + * @param {HTMLElement} domElement - The parent element containing text input fields to be reset. + */ + resetInputValuesInElement(domElement) { + const inputEvent = new Event('input'); + let textInputs = domElement.querySelectorAll("input[type='text']"); + textInputs.forEach(input => { + // Reset input value to its initial stored value + input.value = input.dataset.initialValue; + // Dispatch input event to update any event-driven changes + input.dispatchEvent(inputEvent); + }); + } + + /** + * Copies values from the editable row's text inputs into the corresponding + * readonly row cells, formatting them appropriately. + * @param {HTMLElement} editRow - The row containing editable input fields. + * @param {HTMLElement} readOnlyRow - The row where values will be displayed in a non-editable format. + */ + copyEditRowToReadonlyRow(editRow, readOnlyRow) { + let textInputs = editRow.querySelectorAll("input[type='text']"); + let tds = readOnlyRow.querySelectorAll("td"); + let updatedText = ''; + + // If a server name exists, store its value + if (textInputs[0]) { + updatedText = textInputs[0].value; + } + + // If an IP address exists, append it in parentheses next to the server name + if (textInputs[1] && textInputs[1].value) { + updatedText = updatedText + " (" + textInputs[1].value + ")"; + } + + // Assign the formatted text to the first column of the readonly row + if (tds[0]) { + tds[0].innerText = updatedText; + } + } + + /** + * Resets the form change state. + * This method marks the form as unchanged by setting `formChanged` to false. + * It is useful for tracking whether a user has modified any form fields. + */ + resetFormChanged() { + this.formChanged = false; + } + + /** + * Removes all existing alert messages from the main content area. + * This ensures that only the latest alert is displayed to the user. + */ + resetAlerts() { + const mainContent = document.getElementById("main-content"); + if (mainContent) { + // Remove all alert elements within the main content area + mainContent.querySelectorAll(".usa-alert:not(.usa-alert--do-not-reset)").forEach(alert => alert.remove()); + } else { + console.warn("Expecting main-content DOM element"); + } + } + + /** + * Displays an alert message at the top of the main content area. + * It first removes any existing alerts before adding a new one to ensure only the latest alert is visible. + * @param {string} level - The alert level (e.g., 'error', 'success', 'warning', 'info'). + * @param {string} message - The message to display inside the alert. + */ + addAlert(level, message) { + this.resetAlerts(); // Remove any existing alerts before adding a new one + + const mainContent = document.getElementById("main-content"); + if (!mainContent) return; + + // Create a new alert div with appropriate classes based on alert level + const alertDiv = document.createElement("div"); + alertDiv.className = `usa-alert usa-alert--${level} usa-alert--slim margin-bottom-2`; + alertDiv.setAttribute("role", "alert"); // Add the role attribute + + // Create the alert body to hold the message text + const alertBody = document.createElement("div"); + alertBody.className = "usa-alert__body"; + alertBody.textContent = message; + + // Append the alert body to the alert div and insert it at the top of the main content area + alertDiv.appendChild(alertBody); + mainContent.insertBefore(alertDiv, mainContent.firstChild); + + // Scroll the page to make the alert visible to the user + scrollToElement("class", "usa-alert__body"); + } +} + +/** + * Initializes the NameserverForm when the DOM is fully loaded. + */ +export function initFormNameservers() { + document.addEventListener('DOMContentLoaded', () => { + if (document.getElementById('nameserver-add-button')) { + const nameserverForm = new NameserverForm(); + nameserverForm.init(); + } + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/formset-forms.js b/src/registrar/assets/src/js/getgov/formset-forms.js index 96d250574..b4a40e5cf 100644 --- a/src/registrar/assets/src/js/getgov/formset-forms.js +++ b/src/registrar/assets/src/js/getgov/formset-forms.js @@ -3,7 +3,7 @@ * We will call this on the forms init, and also every time we add a form * */ -function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){ +function removeForm(e, formLabel, addButton, formIdentifier){ let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); let formToRemove = e.target.closest(".repeatable-form"); formToRemove.remove(); @@ -38,48 +38,7 @@ function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){ node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`); } - - // 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('*')) { - - // Remove the word optional - innerSpan.textContent = innerSpan.textContent.replace(/\s*\(\s*optional\s*\)\s*/, ''); - - // 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; - } }); - - // 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"); - }); - } }); } @@ -131,7 +90,6 @@ function markForm(e, formLabel){ */ 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"); @@ -144,7 +102,7 @@ function prepareNewDeleteButton(btn, formLabel) { } else { // We will remove the forms and re-order the formset btn.addEventListener('click', function(e) { - removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); + removeForm(e, formLabel, addButton, formIdentifier); }); } } @@ -157,7 +115,6 @@ function prepareNewDeleteButton(btn, formLabel) { function prepareDeleteButtons(formLabel) { let formIdentifier = "form" let deleteButtons = document.querySelectorAll(".delete-record"); - let isNameserversForm = document.querySelector(".nameservers-form"); let isOtherContactsForm = document.querySelector(".other-contacts-form"); let addButton = document.querySelector("#add-form"); if (isOtherContactsForm) { @@ -174,7 +131,7 @@ function prepareDeleteButtons(formLabel) { } else { // We will remove the forms and re-order the formset deleteButton.addEventListener('click', function(e) { - removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); + removeForm(e, formLabel, addButton, formIdentifier); }); } }); @@ -214,16 +171,14 @@ export function initFormsetsForms() { let addButton = document.querySelector("#add-form"); let cloneIndex = 0; let formLabel = ''; - let isNameserversForm = document.querySelector(".nameservers-form"); let isOtherContactsForm = document.querySelector(".other-contacts-form"); let isDsDataForm = document.querySelector(".ds-data-form"); let isDotgovDomain = document.querySelector(".dotgov-domain-form"); - // The Nameservers formset features 2 required and 11 optionals - if (isNameserversForm) { - // cloneIndex = 2; - formLabel = "Name server"; + if( !(isOtherContactsForm || isDotgovDomain || isDsDataForm) ){ + return + } // DNSSEC: DS Data - } else if (isDsDataForm) { + if (isDsDataForm) { formLabel = "DS data record"; // The Other Contacts form } else if (isOtherContactsForm) { @@ -235,11 +190,6 @@ export function initFormsetsForms() { } 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() @@ -258,33 +208,6 @@ export function initFormsetsForms() { // For the eample on Nameservers let formExampleRegex = RegExp(`ns(\\d){1}`, 'g'); - // Some Nameserver form checks since the delete can mess up the source object we're copying - // in regards to required fields and hidden delete buttons - if (isNameserversForm) { - - // If the source element we're copying has required on an input, - // reset that input - let formRequiredNeedsCleanUp = newForm.innerHTML.includes('*'); - if (formRequiredNeedsCleanUp) { - newForm.querySelector('label abbr').remove(); - // Get all input elements within the container - const inputElements = newForm.querySelectorAll("input"); - // Loop through each input element and remove the 'required' attribute - inputElements.forEach((input) => { - if (input.hasAttribute("required")) { - input.removeAttribute("required"); - } - }); - } - - // If the source element we're copying has an disabled delete button, - // enable that button - let deleteButton= newForm.querySelector('.delete-record'); - if (deleteButton.hasAttribute("disabled")) { - deleteButton.removeAttribute("disabled"); - } - } - formNum++; newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `${formIdentifier}-${formNum-1}-`); @@ -305,14 +228,7 @@ export function initFormsetsForms() { deleteButton.setAttribute("aria-labelledby", header.id); deleteButton.setAttribute("aria-describedby", deleteDescription.id); } else { - // Nameservers form is cloned from index 2 which has the word optional on init, does not have the word optional - // if indices 0 or 1 have been deleted - let containsOptional = newForm.innerHTML.includes('(optional)'); - if (isNameserversForm && !containsOptional) { - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum} (optional)`); - } else { - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); - } + newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); } newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`); newForm.innerHTML = newForm.innerHTML.replace(/\n/g, ''); // Remove newline characters @@ -369,20 +285,6 @@ export function initFormsetsForms() { let newDeleteButton = newForm.querySelector(".delete-record"); if (newDeleteButton) prepareNewDeleteButton(newDeleteButton, formLabel); - - // Disable the add more button if we have 13 forms - if (isNameserversForm && formNum == 13) { - addButton.setAttribute("disabled", "true"); - } - - if (isNameserversForm && forms.length >= 2) { - // Enable the delete buttons on the nameservers - forms.forEach((form, index) => { - Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { - deleteButton.removeAttribute("disabled"); - }); - }); - } } } @@ -408,22 +310,3 @@ export function triggerModalOnDsDataForm() { }, 50); } } - -/** - * Disable the delete buttons on nameserver forms on page load if < 3 forms - * - */ -export function nameserversFormListener() { - let isNameserversForm = document.querySelector(".nameservers-form"); - if (isNameserversForm) { - let forms = document.querySelectorAll(".repeatable-form"); - if (forms.length < 3) { - // Hide the delete buttons on the 2 nameservers - forms.forEach((form) => { - Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { - deleteButton.setAttribute("disabled", "true"); - }); - }); - } - } -} diff --git a/src/registrar/assets/src/js/getgov/helpers.js b/src/registrar/assets/src/js/getgov/helpers.js index 08be011c2..80a9fce1f 100644 --- a/src/registrar/assets/src/js/getgov/helpers.js +++ b/src/registrar/assets/src/js/getgov/helpers.js @@ -84,19 +84,6 @@ export function getCsrfToken() { return document.querySelector('input[name="csrfmiddlewaretoken"]').value; } -/** - * Helper function to submit a form - * @param {} form_id - the id of the form to be submitted - */ -export function submitForm(form_id) { - let form = document.getElementById(form_id); - if (form) { - form.submit(); - } else { - console.error("Form '" + form_id + "' not found."); - } -} - /** * Helper function to strip HTML tags * THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index c95bf2144..0529d3614 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -1,6 +1,7 @@ -import { hookupYesNoListener, hookupRadioTogglerListener } from './radios.js'; +import { hookupYesNoListener } from './radios.js'; import { initDomainValidators } from './domain-validators.js'; -import { initFormsetsForms, triggerModalOnDsDataForm, nameserversFormListener } from './formset-forms.js'; +import { initFormsetsForms, triggerModalOnDsDataForm } from './formset-forms.js'; +import { initFormNameservers } from './form-nameservers' import { initializeUrbanizationToggle } from './urbanization.js'; import { userProfileListener, finishUserSetupListener } from './user-profile.js'; import { handleRequestingEntityFieldset } from './requesting-entity.js'; @@ -21,7 +22,7 @@ initDomainValidators(); initFormsetsForms(); triggerModalOnDsDataForm(); -nameserversFormListener(); +initFormNameservers(); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index 1442acf1f..71894ce59 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -190,6 +190,9 @@ abbr[title] { .visible-mobile-flex { display: none!important; } + .text-right--tablet { + text-align: right; + } } @@ -286,3 +289,11 @@ Fit-content itself does not work. width: 3%; padding-right: 0px !important; } + +.width-40p { + width: 40%; +} + +.minh-143px { + min-height: 143px; +} diff --git a/src/registrar/assets/src/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss index 222f44fcc..509bdc573 100644 --- a/src/registrar/assets/src/sass/_theme/_tables.scss +++ b/src/registrar/assets/src/sass/_theme/_tables.scss @@ -11,6 +11,11 @@ th { border: none; } + td.padding-right-0, + th.padding-right-0 { + padding-right: 0; + } + tr:first-child th:first-child { border-top: none; } diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 05eb90db3..538edc7ab 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -65,7 +65,12 @@ class DomainNameserverForm(forms.Form): domain = forms.CharField(widget=forms.HiddenInput, required=False) - server = forms.CharField(label="Name server", strip=True) + server = forms.CharField( + label="Name server", + strip=True, + required=True, + error_messages={"required": "At least two name servers are required."}, + ) ip = forms.CharField( label="IP address (IPv4 or IPv6)", @@ -76,13 +81,6 @@ class DomainNameserverForm(forms.Form): def __init__(self, *args, **kwargs): super(DomainNameserverForm, self).__init__(*args, **kwargs) - # add custom error messages - self.fields["server"].error_messages.update( - { - "required": "At least two name servers are required.", - } - ) - def clean(self): # clean is called from clean_forms, which is called from is_valid # after clean_fields. it is used to determine form level errors. @@ -183,43 +181,83 @@ class DomainSuborganizationForm(forms.ModelForm): class BaseNameserverFormset(forms.BaseFormSet): def clean(self): - """ - Check for duplicate entries in the formset. - """ + """Check for duplicate entries in the formset and ensure at least two valid nameservers.""" + error_message = "At least two name servers are required." - # Check if there are at least two valid servers - valid_servers_count = sum( - 1 for form in self.forms if form.cleaned_data.get("server") and form.cleaned_data.get("server").strip() - ) - if valid_servers_count >= 2: - # If there are, remove the "At least two name servers are required" error from each form - # This will allow for successful submissions when the first or second entries are blanked - # but there are enough entries total - for form in self.forms: - if form.errors.get("server") == ["At least two name servers are required."]: - form.errors.pop("server") + valid_forms, invalid_forms, empty_forms = self._categorize_forms(error_message) + self._enforce_minimum_nameservers(valid_forms, invalid_forms, empty_forms, error_message) - if any(self.errors): - # Don't bother validating the formset unless each form is valid on its own + if any(self.errors): # Skip further validation if individual forms already have errors return - data = [] + self._check_for_duplicates() + + def _categorize_forms(self, error_message): + """Sort forms into valid, invalid or empty based on the 'server' field.""" + valid_forms = [] + invalid_forms = [] + empty_forms = [] + + for form in self.forms: + if not self._is_server_validation_needed(form, error_message): + invalid_forms.append(form) + continue + server = form.cleaned_data.get("server", "").strip() + if server: + valid_forms.append(form) + else: + empty_forms.append(form) + + return valid_forms, invalid_forms, empty_forms + + def _is_server_validation_needed(self, form, error_message): + """Determine if server validation should be performed on a given form.""" + return form.is_valid() or list(form.errors.get("server", [])) == [error_message] + + def _enforce_minimum_nameservers(self, valid_forms, invalid_forms, empty_forms, error_message): + """Ensure at least two nameservers are provided, adjusting error messages as needed.""" + if len(valid_forms) + len(invalid_forms) < 2: + self._add_required_error(empty_forms, error_message) + else: + self._remove_required_error_from_forms(error_message) + + def _add_required_error(self, empty_forms, error_message): + """Add 'At least two name servers' error to one form and remove duplicates.""" + error_added = False + + for form in empty_forms: + if list(form.errors.get("server", [])) == [error_message]: + form.errors.pop("server") + + if not error_added: + form.add_error("server", error_message) + error_added = True + + def _remove_required_error_from_forms(self, error_message): + """Remove the 'At least two name servers' error from all forms if sufficient nameservers exist.""" + for form in self.forms: + if form.errors.get("server") == [error_message]: + form.errors.pop("server") + + def _check_for_duplicates(self): + """Ensure no duplicate nameservers exist within the formset.""" + seen_servers = set() duplicates = [] - for index, form in enumerate(self.forms): - if form.cleaned_data: - value = form.cleaned_data["server"] - # We need to make sure not to trigger the duplicate error in case the first and second nameservers - # are empty. If there are enough records in the formset, that error is an unecessary blocker. - # If there aren't, the required error will block the submit. - if value in data and not (form.cleaned_data.get("server", "").strip() == "" and index == 1): - form.add_error( - "server", - NameserverError(code=nsErrorCodes.DUPLICATE_HOST, nameserver=value), - ) - duplicates.append(value) - else: - data.append(value) + for form in self.forms: + if not form.cleaned_data: + continue + + server = form.cleaned_data["server"].strip() + + if server and server in seen_servers: + form.add_error( + "server", + NameserverError(code=nsErrorCodes.DUPLICATE_HOST, nameserver=server), + ) + duplicates.append(server) + else: + seen_servers.add(server) NameserverFormset = formset_factory( diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index 58038d0a4..249f69d32 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -46,7 +46,7 @@ {# messages block is under the back breadcrumb link #} {% if messages %} {% for message in messages %} -
+