diff --git a/src/api/views.py b/src/api/views.py index dcda09c69..deaacf0c6 100644 --- a/src/api/views.py +++ b/src/api/views.py @@ -1,7 +1,5 @@ """Internal API views""" - from django.apps import apps -from django.core.exceptions import BadRequest from django.views.decorators.http import require_http_methods from django.http import JsonResponse @@ -17,6 +15,19 @@ DOMAIN_FILE_URL = ( ) +DOMAIN_API_MESSAGES = { + "required": "Enter the .gov domain you want. Don’t include “www” or “.gov.”" + " For example, if you want www.city.gov, you would enter “city”" + " (without the quotes).", + "extra_dots": "Enter the .gov domain you want without any periods.", + "unavailable": "That domain isn’t available. Try entering another one." + " Contact us if you need help coming up with a domain.", + "invalid": "Enter a domain using only letters," + " numbers, or hyphens (though we don't recommend using hyphens).", + "success": "That domain is available!", +} + + # this file doesn't change that often, nor is it that big, so cache the result # in memory for ten minutes @ttl_cache(ttl=600) @@ -72,6 +83,18 @@ def available(request, domain=""): Domain.string_could_be_domain(domain) or Domain.string_could_be_domain(domain + ".gov") ): - raise BadRequest("Invalid request.") + return JsonResponse({ + "available": False, + "message": DOMAIN_API_MESSAGES["invalid"] + }) # a domain is available if it is NOT in the list of current domains - return JsonResponse({"available": not in_domains(domain)}) + if in_domains(domain): + return JsonResponse({ + "available": False, + "message": DOMAIN_API_MESSAGES["unavailable"] + }) + else: + return JsonResponse({ + "available": True, + "message": DOMAIN_API_MESSAGES["success"] + }) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 44d0cab57..c9f6d77af 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -9,6 +9,11 @@ var DEFAULT_ERROR = "Please check this field for errors."; +var INFORMATIVE = "info"; +var WARNING = "warning"; +var ERROR = "error"; +var SUCCESS = "success"; + // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Helper functions. @@ -89,44 +94,114 @@ function toggleInputValidity(el, valid, msg=DEFAULT_ERROR) { el.classList.remove('usa-input--success'); el.setAttribute("aria-invalid", "true"); el.setCustomValidity(msg); - // this is here for testing: in actual use, we might not want to - // visually display these errors until the user tries to submit el.classList.add('usa-input--error'); } } -function _checkDomainAvailability(e) { +/** Display (or hide) a message beneath an element. */ +function inlineToast(el, id, style, msg) { + if (!el.id && !id) { + console.error("Elements must have an `id` to show an inline toast."); + return; + } + let toast = document.getElementById((el.id || id) + "--toast"); + if (style) { + if (!toast) { + // create and insert the message div + toast = document.createElement("div"); + const toastBody = document.createElement("div"); + const p = document.createElement("p"); + toast.setAttribute("id", (el.id || id) + "--toast"); + toast.className = `usa-alert usa-alert--${style} usa-alert--slim`; + toastBody.classList.add("usa-alert__body"); + p.classList.add("usa-alert__text"); + p.innerText = msg; + toastBody.appendChild(p); + toast.appendChild(toastBody); + el.parentNode.insertBefore(toast, el.nextSibling); + } else { + // update and show the existing message div + toast.className = `usa-alert usa-alert--${style} usa-alert--slim`; + toast.querySelector("div p").innerText = msg; + makeVisible(toast); + } + } else { + if (toast) makeHidden(toast); + } +} + +function _checkDomainAvailability(el) { const callback = (response) => { - toggleInputValidity(e.target, (response && response.available)); - if (e.target.validity.valid) { - e.target.classList.add('usa-input--success'); - // do other stuff, like display a toast? + toggleInputValidity(el, (response && response.available), msg=response.message); + announce(el.id, response.message); + if (el.validity.valid) { + el.classList.add('usa-input--success'); + // use of `parentElement` due to .gov inputs being wrapped in www/.gov decoration + inlineToast(el.parentElement, el.id, SUCCESS, response.message); + } else { + inlineToast(el.parentElement, el.id, ERROR, response.message); } } - fetchJSON(`available/${e.target.value}`, callback); + fetchJSON(`available/${el.value}`, callback); } /** Call the API to see if the domain is good. */ const checkDomainAvailability = debounce(_checkDomainAvailability); -// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> -// Event handlers. +/** Hides the toast message and clears the aira live region. */ +function clearDomainAvailability(el) { + el.classList.remove('usa-input--success'); + announce(el.id, ""); + // use of `parentElement` due to .gov inputs being wrapped in www/.gov decoration + inlineToast(el.parentElement, el.id); +} - -/** On input change, handles running any associated validators. */ -function handleInputValidation(e) { - const attribute = e.target.getAttribute("validate") || ""; +/** Runs all the validators associated with this element. */ +function runValidators(el) { + const attribute = el.getAttribute("validate") || ""; if (!attribute.length) return; const validators = attribute.split(" "); let isInvalid = false; for (const validator of validators) { switch (validator) { case "domain": - checkDomainAvailability(e); + checkDomainAvailability(el); break; } } - toggleInputValidity(e.target, !isInvalid); + toggleInputValidity(el, !isInvalid); +} + +/** Clears all the validators associated with this element. */ +function clearValidators(el) { + const attribute = el.getAttribute("validate") || ""; + if (!attribute.length) return; + const validators = attribute.split(" "); + for (const validator of validators) { + switch (validator) { + case "domain": + clearDomainAvailability(el); + break; + } + } + toggleInputValidity(el, true); +} + +// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> +// Event handlers. + +/** On input change, handles running any associated validators. */ +function handleInputValidation(e) { + clearValidators(e.target); + if (e.target.hasAttribute("auto-validate")) runValidators(e.target); +} + +/** On button click, handles running any associated validators. */ +function handleValidationClick(e) { + const attribute = e.target.getAttribute("validate-for") || ""; + if (!attribute.length) return; + const input = document.getElementById(attribute); + runValidators(input); } // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> @@ -135,8 +210,12 @@ function handleInputValidation(e) { /** * An IIFE that will attach validators to inputs. * - * It looks for elements with `validate=" "` and adds - * change handlers for each known type. + * It looks for elements with `validate=" "` and adds change handlers. + * + * These handlers know about two other attributes: + * - `validate-for=""` creates a button which will run the validator(s) on + * - `auto-validate` will run validator(s) when the user stops typing (otherwise, + * they will only run when a user clicks the button with `validate-for`) */ (function validatorsInit() { "use strict"; @@ -144,6 +223,10 @@ function handleInputValidation(e) { for(const input of needsValidation) { input.addEventListener('input', handleInputValidation); } + const activatesValidation = document.querySelectorAll('[validate-for]'); + for(const button of activatesValidation) { + button.addEventListener('click', handleValidationClick); + } })(); diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index cec6a777f..b1033a52d 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -7,6 +7,8 @@ from phonenumber_field.formfields import PhoneNumberField # type: ignore from django import forms from django.core.validators import RegexValidator +from api.views import DOMAIN_API_MESSAGES + from registrar.models import Contact, DomainApplication, Domain from registrar.utility import errors @@ -414,36 +416,25 @@ CurrentSitesFormSet = forms.formset_factory( formset=BaseCurrentSitesFormSet, ) - class AlternativeDomainForm(RegistrarForm): - alternative_domain = forms.CharField( - required=False, - label="Alternative domain", - ) - def clean_alternative_domain(self): """Validation code for domain names.""" try: requested = self.cleaned_data.get("alternative_domain", None) validated = Domain.validate(requested, blank_ok=True) except errors.ExtraDotsError: - raise forms.ValidationError( - "Please enter a domain without any periods.", - code="invalid", - ) + raise forms.ValidationError(code="extra_dots") except errors.DomainUnavailableError: - raise forms.ValidationError( - "ERROR MESSAGE GOES HERE", - code="invalid", - ) + raise forms.ValidationError(code="unavailable") except ValueError: - raise forms.ValidationError( - "Please enter a valid domain name using only letters, " - "numbers, and hyphens", - code="invalid", - ) + raise forms.ValidationError(code="invalid") return validated + alternative_domain = forms.CharField( + required=False, + label="Alternative domain", + error_messages=DOMAIN_API_MESSAGES + ) class BaseAlternativeDomainFormSet(RegistrarFormSet): JOIN = "alternative_domains" @@ -517,32 +508,19 @@ class DotGovDomainForm(RegistrarForm): requested = self.cleaned_data.get("requested_domain", None) validated = Domain.validate(requested) except errors.BlankValueError: - # none or empty string - raise forms.ValidationError( - "Enter the .gov domain you want. Don’t include “www” or “.gov.” For" - " example, if you want www.city.gov, you would enter “city” (without" - " the quotes).", - code="invalid", - ) + raise forms.ValidationError(code="required") except errors.ExtraDotsError: - raise forms.ValidationError( - "Enter the .gov domain you want without any periods.", - code="invalid", - ) + raise forms.ValidationError(code="extra_dots") except errors.DomainUnavailableError: - raise forms.ValidationError( - "ERROR MESSAGE GOES HERE", - code="invalid", - ) + raise forms.ValidationError(code="unavailable") except ValueError: - raise forms.ValidationError( - "Enter a domain using only letters, " - "numbers, or hyphens (though we don't recommend using hyphens).", - code="invalid", - ) + raise forms.ValidationError(code="invalid") return validated - requested_domain = forms.CharField(label="What .gov domain do you want?") + requested_domain = forms.CharField( + label="What .gov domain do you want?", + error_messages=DOMAIN_API_MESSAGES + ) class PurposeForm(RegistrarForm): diff --git a/src/registrar/templates/application_dotgov_domain.html b/src/registrar/templates/application_dotgov_domain.html index 02bc9522b..8d7a2718d 100644 --- a/src/registrar/templates/application_dotgov_domain.html +++ b/src/registrar/templates/application_dotgov_domain.html @@ -1,6 +1,5 @@ {% extends 'application_form.html' %} -{% load static %} -{% load field_helpers %} +{% load static field_helpers %} {% block form_instructions %}

Before requesting a .gov domain, please make sure it @@ -29,7 +28,6 @@ {% block form_fields %} - {% csrf_token %} {{ forms.0.management_form }} @@ -44,12 +42,16 @@ complete and submit the rest of this form.

{% with attr_aria_describedby="domain_instructions domain_instructions2" %} + {# attr_validate / validate="domain" invokes code in get-gov.js #} {% with www_gov=True attr_validate="domain" %} {% input_with_errors forms.0.requested_domain %} {% endwith %} {% endwith %} - - + {{ forms.1.management_form }} @@ -63,7 +65,9 @@ you your first choice? Entering alternative domains is optional.

{% with attr_aria_describedby="alt_domain_instructions" %} - {% with www_gov=True attr_validate="domain" %} + {# attr_validate / validate="domain" invokes code in get-gov.js #} + {# attr_auto_validate likewise triggers behavior in get-gov.js #} + {% with www_gov=True attr_validate="domain" attr_auto_validate=True %} {% for form in forms.1 %} {% input_with_errors form.alternative_domain %} {% endfor %} diff --git a/src/registrar/templates/application_form.html b/src/registrar/templates/application_form.html index c52a5904e..9b1240013 100644 --- a/src/registrar/templates/application_form.html +++ b/src/registrar/templates/application_form.html @@ -47,6 +47,7 @@ {% endblock %}
+ {% csrf_token %} {% block form_fields %}{% endblock %}