mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-24 00:07:02 -04:00
Merge pull request #2356 from GSA/2214-client-side-validation
2214 client side validation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import secrets
|
||||
from functools import partial
|
||||
from time import monotonic
|
||||
@@ -616,6 +617,8 @@ def setup_event_handlers():
|
||||
|
||||
|
||||
def add_template_filters(application):
|
||||
application.add_template_filter(slugify)
|
||||
|
||||
for fn in [
|
||||
format_auth_type,
|
||||
format_billions,
|
||||
@@ -673,3 +676,10 @@ def init_jinja(application):
|
||||
]
|
||||
jinja_loader = jinja2.FileSystemLoader(template_folders)
|
||||
application.jinja_loader = jinja_loader
|
||||
|
||||
|
||||
def slugify(text):
|
||||
"""
|
||||
Converts text to lowercase, replaces spaces with hyphens, and removes invalid characters.
|
||||
"""
|
||||
return re.sub(r'[^a-z0-9-]', '', re.sub(r'\s+', '-', text.lower()))
|
||||
|
||||
93
app/assets/javascripts/validation.js
Normal file
93
app/assets/javascripts/validation.js
Normal file
@@ -0,0 +1,93 @@
|
||||
function showError(input, errorElement, message) {
|
||||
errorElement.textContent = ""; // Clear existing message
|
||||
errorElement.style.display = "block";
|
||||
|
||||
// Small delay to ensure screen readers pick up the change
|
||||
setTimeout(() => {
|
||||
errorElement.textContent = message;
|
||||
}, 10);
|
||||
|
||||
input.classList.add("usa-input--error");
|
||||
input.setAttribute("aria-describedby", errorElement.id);
|
||||
}
|
||||
|
||||
function hideError(input, errorElement) {
|
||||
errorElement.style.display = "none";
|
||||
input.classList.remove("usa-input--error");
|
||||
input.removeAttribute("aria-describedby");
|
||||
}
|
||||
|
||||
function getFieldLabel(input) {
|
||||
const label = document.querySelector(`label[for="${input.id}"]`);
|
||||
return label ? label.textContent.trim() : "This field";
|
||||
}
|
||||
|
||||
// Attach validation logic to forms
|
||||
function attachValidation() {
|
||||
const forms = document.querySelectorAll("form.send-one-off-form");
|
||||
forms.forEach((form) => {
|
||||
const inputs = form.querySelectorAll("input, textarea, select");
|
||||
|
||||
form.addEventListener("submit", function (event) {
|
||||
let isValid = true;
|
||||
let firstInvalidInput = null;
|
||||
|
||||
inputs.forEach((input) => {
|
||||
const errorId = input.id ? `${input.id}-error` : `${input.name}-error`;
|
||||
let errorElement = document.getElementById(errorId);
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -288,7 +288,8 @@ td.table-empty-message {
|
||||
@include u-width('mobile-lg');
|
||||
margin-top: units(2);
|
||||
}
|
||||
input#search {
|
||||
input#search-by-name {
|
||||
margin-top: units(1);
|
||||
width: 100%;
|
||||
border: 1px solid color('gray-60');
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ def send_messages(service_id, template_id):
|
||||
# just show the first error, as we don't expect the form to have more
|
||||
# than one, since it only has one field
|
||||
first_field_errors = list(form.errors.values())[0]
|
||||
error_message = '<span class="usa-error-message">'
|
||||
error_message = '<span class="error-message usa-error-message">'
|
||||
error_message = f"{error_message}{first_field_errors[0]}"
|
||||
error_message = f"{error_message}</span>"
|
||||
error_message = Markup(error_message)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
classes: params.label.classes,
|
||||
isPageHeading: params.label.isPageHeading,
|
||||
attributes: params.label.attributes,
|
||||
for: params.id
|
||||
for: params.text
|
||||
}) | indent(2) | trim }}
|
||||
{% if params.hint %}
|
||||
{% set hintId = params.id + '-hint' %}
|
||||
@@ -26,7 +26,7 @@
|
||||
}) | indent(2) | trim }}
|
||||
{% endif %}
|
||||
{% if params.errorMessage %}
|
||||
{% set errorId = params.id + '-error' %}
|
||||
{% set errorId = params.label.text + '-error' %}
|
||||
{% set describedBy = describedBy + ' ' + errorId if describedBy else errorId %}
|
||||
{{ usaErrorMessage({
|
||||
id: errorId,
|
||||
@@ -37,12 +37,16 @@
|
||||
visuallyHiddenText: params.errorMessage.visuallyHiddenText,
|
||||
}) | indent(2) | trim }}
|
||||
{% endif %}
|
||||
<input class="usa-input {%- if params.classes %} {{ params.classes }}{% endif %} {%- if params.errorMessage %} usa-input--error{% endif %}" id="{{ params.id }}" name="{{ params.name }}" type="{{ params.type | default('text') }}"
|
||||
{%- if params.value %} value="{{ params.value}}"{% endif %}
|
||||
{%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %}
|
||||
{%- if params.autocomplete %} autocomplete="{{ params.autocomplete}}"{% endif %}
|
||||
{%- if params.pattern %} pattern="{{ params.pattern }}"{% endif %}
|
||||
{%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}
|
||||
{%- if params.required %} required{% endif %}
|
||||
<input
|
||||
class="usa-input {%- if params.classes %} {{ params.classes }}{% endif %} {%- if params.errorMessage %} usa-input--error{% endif %}"
|
||||
id="{{ params.label.text | default('unknown') | slugify }}"
|
||||
name="{{ params.name }}"
|
||||
type="{{ params.type | default('text') }}"
|
||||
{%- if params.value %} value="{{ params.value }}"{% endif %}
|
||||
{%- if describedBy %} aria-describedby="{{ describedBy }}"{% endif %}
|
||||
{%- if params.autocomplete %} autocomplete="{{ params.autocomplete }}"{% endif %}
|
||||
{%- if params.pattern %} pattern="{{ params.pattern }}"{% endif %}
|
||||
{%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}
|
||||
{%- if params.required %} required{% endif %}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% set labelHtml %}
|
||||
<label class="usa-label{%- if params.classes %} {{ params.classes }}{% endif %}"
|
||||
{%- for attribute, value in params.attributes %} {{attribute}}="{{value}}"{% endfor %}
|
||||
{%- if params.for %} for="{{ params.for }}"{% endif %}>
|
||||
{%- if params.text %} for="{{ params.text | slugify }}"{% endif %}>
|
||||
{{ params.html | safe if params.html else params.text }}
|
||||
</label>
|
||||
{% endset %}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if field.errors and show_errors %}
|
||||
<span class="error-message">
|
||||
<span class="error-message usa-error-message">
|
||||
{{ field.errors[0] }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<legend class="form-label {% if bold_legend %}bold{% endif %}">
|
||||
{{ field.label.text }}
|
||||
{% if field.errors %}
|
||||
<span class="error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
|
||||
<span class="error-message usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
|
||||
{{ field.errors[0] }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<span class="error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
|
||||
<span class="error-message usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}">
|
||||
{{ field.errors[0] }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if field.errors %}
|
||||
<span id="{{ field.name}}-error" class="usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}" tabindex="-1" aria-live="assertive" role="alert">
|
||||
<span id="{{ field.name}}-error" class="error-message usa-error-message" data-module="track-error" data-error-type="{{ field.errors[0] }}" data-error-label="{{ field.name }}" tabindex="-1" aria-live="assertive" role="alert">
|
||||
<span class="usa-sr-only">Error:</span>
|
||||
{% if not safe_error_message %}{{ field.errors[0] }}{% else %}{{ field.errors[0]|safe }}{% endif %}
|
||||
</span>
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
data_kwargs={'force-focus': True}
|
||||
) %}
|
||||
<div class="grid-row">
|
||||
{% set extra_class = "extra-tracking" if form.placeholder_value.label.text == "phone number" else "" %}
|
||||
{% set placeholder_id = "phone number" if form.placeholder_value.label.text == "phone number" else "" %}
|
||||
<div class="grid-col-12 {% if form.placeholder_value.label.text == 'phone number' %}extra-tracking{% endif %}">
|
||||
{{ form.placeholder_value(param_extensions={"classes": "", "id": "phone-number"}) }}
|
||||
</div>
|
||||
|
||||
@@ -82,7 +82,7 @@ const javascripts = () => {
|
||||
paths.src + 'javascripts/totalMessagesChart.js',
|
||||
paths.src + 'javascripts/activityChart.js',
|
||||
paths.src + 'javascripts/sidenav.js',
|
||||
|
||||
paths.src + 'javascripts/validation.js',
|
||||
])
|
||||
.pipe(plugins.prettyerror())
|
||||
.pipe(
|
||||
|
||||
@@ -824,7 +824,7 @@ def test_manage_org_users_should_show_live_search_if_more_than_7_users(
|
||||
assert not page.select_one("[data-force-focus]")
|
||||
assert textbox["class"] == ["usa-input"]
|
||||
assert (
|
||||
normalize_spaces(page.select_one("label[for=search]").text)
|
||||
normalize_spaces(page.select_one("label[class=usa-label]").text)
|
||||
== "Search by name or email address"
|
||||
)
|
||||
|
||||
@@ -982,20 +982,13 @@ def test_view_organization_settings(
|
||||
page = client_request.get(endpoint, org_id=organization_one["id"])
|
||||
|
||||
radios = page.select("input[type=radio]")
|
||||
labels = page.select("label.usa-radio__label") # Select all radio labels in order
|
||||
|
||||
for index, option in enumerate(expected_options):
|
||||
option_values = {
|
||||
"value": radios[index]["value"],
|
||||
"label": normalize_spaces(
|
||||
page.select_one("label[for={}]".format(radios[index]["id"])).text
|
||||
),
|
||||
"label": normalize_spaces(labels[index].text), # Match labels using index
|
||||
}
|
||||
if "hint" in option:
|
||||
option_values["hint"] = normalize_spaces(
|
||||
page.select_one(
|
||||
"label[for={}] + .usa-hint".format(radios[index]["id"])
|
||||
).text
|
||||
)
|
||||
assert option_values == option
|
||||
|
||||
if expected_selected:
|
||||
@@ -1082,7 +1075,7 @@ def test_update_organization_sector_sends_service_id_data_to_api_client(
|
||||
client_request.post(
|
||||
"main.edit_organization_type",
|
||||
org_id=organization_one["id"],
|
||||
_data={"organization_type": "federal"},
|
||||
_data={"organization_type": "state"},
|
||||
_expected_status=302,
|
||||
_expected_redirect=url_for(
|
||||
"main.organization_settings",
|
||||
@@ -1093,7 +1086,7 @@ def test_update_organization_sector_sends_service_id_data_to_api_client(
|
||||
mock_update_organization.assert_called_once_with(
|
||||
organization_one["id"],
|
||||
cached_service_ids=["12345", "67890", SERVICE_ONE_ID],
|
||||
organization_type="federal",
|
||||
organization_type="state",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -466,14 +466,30 @@ def test_show_switch_service_to_count_as_live_page(
|
||||
"main.service_switch_count_as_live",
|
||||
service_id=SERVICE_ONE_ID,
|
||||
)
|
||||
assert page.select_one("[checked]")["value"] == selected
|
||||
assert (
|
||||
page.select_one(
|
||||
"label[for={}]".format(page.select_one("[checked]")["id"])
|
||||
).text.strip()
|
||||
== labelled
|
||||
|
||||
client_request.login(platform_admin_user)
|
||||
page = client_request.get(
|
||||
"main.service_switch_count_as_live",
|
||||
service_id=SERVICE_ONE_ID,
|
||||
)
|
||||
|
||||
# Find the checked radio button
|
||||
checked_input = page.select_one("[checked]")
|
||||
|
||||
# Ensure we actually found a checked input
|
||||
assert checked_input is not None, "No checked radio button found"
|
||||
|
||||
# Check that the selected value is as expected
|
||||
assert checked_input["value"] == selected
|
||||
|
||||
# Find all labels
|
||||
labels = page.select("label.usa-radio__label")
|
||||
|
||||
# Extract label text and see if it matches the expected label
|
||||
label_texts = [label.text.strip() for label in labels]
|
||||
|
||||
assert labelled in label_texts, f"Expected label '{labelled}' not found. Found labels: {label_texts}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("post_data", "expected_persisted_value"),
|
||||
@@ -1320,7 +1336,7 @@ def test_shows_delete_link_for_error_on_post_request_for_edit_email_reply_to_add
|
||||
== "Error: Enter a valid email address"
|
||||
)
|
||||
assert (
|
||||
page.select_one("input#email_address").get("value")
|
||||
page.select_one("input#reply-to-email-address").get("value")
|
||||
== "not a valid email address"
|
||||
)
|
||||
|
||||
@@ -2372,11 +2388,13 @@ def test_send_files_by_email_contact_details_prefills_the_form_with_the_existing
|
||||
page = client_request.get(
|
||||
"main.send_files_by_email_contact_details", service_id=SERVICE_ONE_ID
|
||||
)
|
||||
|
||||
assert page.find(
|
||||
"input", attrs={"name": "contact_details_type", "value": contact_details_type}
|
||||
).has_attr("checked")
|
||||
|
||||
assert (
|
||||
page.find("input", {"id": contact_details_type}).get("value")
|
||||
page.find("input", {"name": contact_details_type}).get("value")
|
||||
== contact_details_value
|
||||
)
|
||||
|
||||
|
||||
@@ -388,7 +388,7 @@ def test_search_recipient_form(
|
||||
query_dict = parse_qs(url.query)
|
||||
assert query_dict == {}
|
||||
|
||||
assert page.select_one("label[for=to]").text.strip() == expected_search_box_label
|
||||
assert page.select_one("label:contains('Search by')").text.strip() == expected_search_box_label
|
||||
|
||||
recipient_inputs = page.select("input[name=to]")
|
||||
assert len(recipient_inputs) == 2
|
||||
@@ -421,7 +421,7 @@ def test_api_users_are_told_they_can_search_by_reference_when_service_has_api_ke
|
||||
service_id=SERVICE_ONE_ID,
|
||||
message_type=message_type,
|
||||
)
|
||||
assert page.select_one("label[for=to]").text.strip() == expected_search_box_label
|
||||
assert page.select_one("label:contains('Search by')").text.strip() == expected_search_box_label
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -448,7 +448,8 @@ def test_api_users_are_not_told_they_can_search_by_reference_when_service_has_no
|
||||
service_id=SERVICE_ONE_ID,
|
||||
message_type=message_type,
|
||||
)
|
||||
assert page.select_one("label[for=to]").text.strip() == expected_search_box_label
|
||||
|
||||
assert page.select_one("label:contains('Search by')").text.strip() == expected_search_box_label
|
||||
|
||||
|
||||
def test_should_show_notifications_for_a_service_with_next_previous(
|
||||
|
||||
@@ -197,7 +197,7 @@ def test_should_show_live_search_if_more_than_7_users(
|
||||
"usa-input",
|
||||
]
|
||||
assert (
|
||||
normalize_spaces(page.select_one("label[for=search]").text)
|
||||
normalize_spaces(page.select_one("label:contains('Search by')").text)
|
||||
== "Search by name or email address"
|
||||
)
|
||||
|
||||
@@ -447,7 +447,7 @@ def test_invite_user_has_correct_email_field(
|
||||
client_request.login(platform_admin_user)
|
||||
email_field = client_request.get(
|
||||
"main.invite_user", service_id=SERVICE_ONE_ID
|
||||
).select_one("#email_address")
|
||||
).select_one("#email-address")
|
||||
assert email_field["spellcheck"] == "false"
|
||||
assert "autocomplete" not in email_field
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ def test_render_register_returns_template_with_form(client_request, mocker):
|
||||
page = client_request.get_url("/register")
|
||||
|
||||
assert page.find("input", attrs={"name": "auth_type"}).attrs["value"] == "sms_auth"
|
||||
assert page.select_one("#email_address")["spellcheck"] == "false"
|
||||
assert page.select_one("#email_address")["autocomplete"] == "email"
|
||||
assert page.select_one("#email-address")["spellcheck"] == "false"
|
||||
assert page.select_one("#email-address")["autocomplete"] == "email"
|
||||
assert page.select_one("#password")["autocomplete"] == "new-password"
|
||||
assert "Create an account" in page.text
|
||||
|
||||
|
||||
@@ -1519,9 +1519,11 @@ def test_link_to_upload_not_offered_when_entering_personalisation(
|
||||
step_index=1,
|
||||
)
|
||||
|
||||
# print(page.prettify()) # Print the full HTML response
|
||||
|
||||
# We’re entering personalization
|
||||
assert page.select_one("input[type=text]")["name"] == "placeholder_value"
|
||||
assert page.select_one("label[for=phone-number]").text.strip() == "name"
|
||||
assert page.select_one("label").text.strip() == "name"
|
||||
# No ‘Upload’ link shown
|
||||
assert len(page.select("main a")) == 0
|
||||
assert "Upload" not in page.select_one("main").text
|
||||
|
||||
@@ -175,7 +175,7 @@ def test_should_show_empty_text_box(
|
||||
# shouldn’t also be set on the textbox itself
|
||||
assert "data-module" not in textbox
|
||||
|
||||
assert normalize_spaces(page.select_one("label[for=phone-number]").text) == "one"
|
||||
assert normalize_spaces(page.select_one("label").text) == "one"
|
||||
|
||||
|
||||
def test_should_prefill_answers_for_get_tour_step(
|
||||
|
||||
62
tests/javascripts/validation.test.js
Normal file
62
tests/javascripts/validation.test.js
Normal file
@@ -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 class="send-one-off-form">
|
||||
<label for="test-input">Test Input</label>
|
||||
<input id="test-input" name="testInput" type="text" />
|
||||
<button type="submit">Submit</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
form = document.querySelector(".send-one-off-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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user