From f4ca59796421370e84464df83b78390d61492fc0 Mon Sep 17 00:00:00 2001 From: Jonathan Bobel Date: Fri, 28 Feb 2025 11:31:47 -0500 Subject: [PATCH] Updates to validation.js and it's test - all are passing --- app/assets/javascripts/validation.js | 124 ++++++++++++++++++--------- tests/javascripts/validation.test.js | 62 ++++++++++++++ 2 files changed, 147 insertions(+), 39 deletions(-) create mode 100644 tests/javascripts/validation.test.js diff --git a/app/assets/javascripts/validation.js b/app/assets/javascripts/validation.js index 28a360380..d7ec7c5e2 100644 --- a/app/assets/javascripts/validation.js +++ b/app/assets/javascripts/validation.js @@ -1,47 +1,93 @@ -// document.addEventListener("DOMContentLoaded", function () { -// const form = document.querySelector(".send-one-off-form"); -// const phoneInput = document.getElementById("phone-number"); +function showError(input, errorElement, message) { + errorElement.textContent = ""; // Clear existing message + errorElement.style.display = "block"; -// // Try to get the error element -// let phoneError = document.getElementById("phone-number-error"); + // Small delay to ensure screen readers pick up the change + setTimeout(() => { + errorElement.textContent = message; + }, 10); -// // If not found, create it -// if (!phoneError) { -// phoneError = document.createElement("span"); -// phoneError.id = "phone-number-error"; -// phoneError.classList.add("usa-error-message"); -// phoneError.style.display = "none"; // Keep it hidden initially -// phoneInput.insertAdjacentElement("afterend", phoneError); -// } + input.classList.add("usa-input--error"); + input.setAttribute("aria-describedby", errorElement.id); +} -// form.addEventListener("submit", function (event) { -// let isValid = true; +function hideError(input, errorElement) { + errorElement.style.display = "none"; + input.classList.remove("usa-input--error"); + input.removeAttribute("aria-describedby"); +} -// if (phoneInput.value.trim() === "") { -// showError(phoneInput, phoneError, "Phone number cannot be empty."); -// isValid = false; -// } +function getFieldLabel(input) { + const label = document.querySelector(`label[for="${input.id}"]`); + return label ? label.textContent.trim() : "This field"; +} -// if (!isValid) event.preventDefault(); -// }); +// Attach validation logic to forms +function attachValidation() { + const forms = document.querySelectorAll("form"); + forms.forEach((form) => { + const inputs = form.querySelectorAll("input, textarea, select"); -// // Remove error when user starts typing -// phoneInput.addEventListener("input", function () { -// if (phoneInput.value.trim() !== "") { -// hideError(phoneInput, phoneError); -// } -// }); + form.addEventListener("submit", function (event) { + let isValid = true; + let firstInvalidInput = null; -// function showError(input, errorElement, message) { -// errorElement.textContent = message; -// errorElement.style.display = "block"; -// input.classList.add("usa-input--error"); -// input.setAttribute("aria-describedby", errorElement.id); -// } + inputs.forEach((input) => { + const errorId = input.id ? `${input.id}-error` : `${input.name}-error`; + let errorElement = document.getElementById(errorId); -// function hideError(input, errorElement) { -// errorElement.style.display = "none"; -// input.classList.remove("usa-input--error"); -// input.removeAttribute("aria-describedby"); -// } -// }); + if (!errorElement) { + errorElement = document.createElement("span"); + errorElement.id = errorId; + errorElement.classList.add("usa-error-message"); + errorElement.setAttribute("aria-live", "polite"); + input.insertAdjacentElement("afterend", errorElement); + } + + if (input.type === "radio") { + // Find all radio buttons with the same name + const radioGroup = document.querySelectorAll(`input[name="${input.name}"]`); + const isChecked = Array.from(radioGroup).some(radio => radio.checked); + + if (!isChecked) { + showError(input, errorElement, `Error: ${getFieldLabel(input)} must be selected.`); + isValid = false; + if (!firstInvalidInput) { + firstInvalidInput = input; + } + } + } else if (input.value.trim() === "") { + showError(input, errorElement, `Error: ${getFieldLabel(input)} is required.`); + isValid = false; + if (!firstInvalidInput) { + firstInvalidInput = input; + } + } + }); + + if (!isValid) { + event.preventDefault(); + if (firstInvalidInput) firstInvalidInput.focus(); + } + }); + + inputs.forEach((input) => { + input.addEventListener("input", function () { + const errorElement = document.getElementById(`${input.id}-error`); + if (input.value.trim() !== "" && errorElement) { + hideError(input, errorElement); + } + }); + }); + }); +} + +// Automatically attach validation only in the browser +if (typeof window !== "undefined") { + document.addEventListener("DOMContentLoaded", attachValidation); +} + +// ✅ Check if we're in a Node.js environment (for Jest) before using `module.exports` +if (typeof module !== "undefined" && typeof module.exports !== "undefined") { + module.exports = { showError, hideError, getFieldLabel, attachValidation }; +} diff --git a/tests/javascripts/validation.test.js b/tests/javascripts/validation.test.js new file mode 100644 index 000000000..dc4bae435 --- /dev/null +++ b/tests/javascripts/validation.test.js @@ -0,0 +1,62 @@ +const { showError, hideError, getFieldLabel, attachValidation } = require("../../app/assets/javascripts/validation.js"); + +describe("Form Validation", () => { + let form, input, submitButton; + + beforeEach(() => { + document.body.innerHTML = ` +
+ + + +
+ `; + + form = document.querySelector(".test-form"); + input = document.getElementById("test-input"); + submitButton = form.querySelector("button"); + + // Manually attach validation logic for Jest + attachValidation(); + }); + + afterEach(() => { + document.body.innerHTML = ""; // Clean up DOM after each test + }); + + test("Displays an error message when input is empty", async () => { + form.dispatchEvent(new Event("submit", { bubbles: true })); + + // Wait for the timeout to complete + await new Promise(resolve => setTimeout(resolve, 20)); + + const errorMessage = document.getElementById("test-input-error"); + expect(errorMessage).not.toBeNull(); + expect(errorMessage.textContent).toBe("Error: Test Input is required."); + expect(input.classList.contains("usa-input--error")).toBe(true); +}); + + test("Removes error message when input is filled", () => { + // Trigger validation first + form.dispatchEvent(new Event("submit", { bubbles: true })); + + // Simulate user typing to remove the error + input.value = "Some text"; + input.dispatchEvent(new Event("input", { bubbles: true })); + + const errorMessage = document.getElementById("test-input-error"); + expect(errorMessage).not.toBeNull(); + expect(errorMessage.style.display).toBe("none"); + expect(input.classList.contains("usa-input--error")).toBe(false); + }); + + test("Focus moves to first invalid input", async () => { + const spy = jest.spyOn(input, "focus"); + + form.dispatchEvent(new Event("submit", { bubbles: true })); + + await new Promise((resolve) => setTimeout(resolve, 10)); // Allow DOM updates + + expect(spy).toHaveBeenCalled(); + }); +});