diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 9a92542b1..0ecec92c6 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -369,7 +369,7 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, } /** An IIFE for admin in DjangoAdmin to listen to changes on the domain request - * status select amd to show/hide the rejection reason + * status select and to show/hide the rejection reason */ (function (){ let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason') diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 587b95305..a8e6008ef 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -193,6 +193,65 @@ function clearValidators(el) { toggleInputValidity(el, true); } +/** Hookup listeners for yes/no togglers for form fields + * Parameters: + * - radioButtonName: The "name=" value for the radio buttons being used as togglers + * - elementIdToShowIfYes: The Id of the element (eg. a div) to show if selected value of the given + * radio button is true (hides this element if false) + * - elementIdToShowIfNo: The Id of the element (eg. a div) to show if selected value of the given + * radio button is false (hides this element if true) + * **/ +function HookupYesNoListener(radioButtonName, elementIdToShowIfYes, elementIdToShowIfNo) { + // Get the radio buttons + let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]'); + + function handleRadioButtonChange() { + // Check the value of the selected radio button + // Attempt to find the radio button element that is checked + let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked'); + + // Check if the element exists before accessing its value + let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; + + switch (selectedValue) { + case 'True': + toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 1); + break; + + case 'False': + toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 2); + break; + + default: + toggleTwoDomElements(elementIdToShowIfYes, elementIdToShowIfNo, 0); + } + } + + if (radioButtons.length) { + // Add event listener to each radio button + radioButtons.forEach(function (radioButton) { + radioButton.addEventListener('change', handleRadioButtonChange); + }); + + // initialize + handleRadioButtonChange(); + } +} + +// 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); + if (element1 || element2) { + // Toggle display based on the index + if (element1) {element1.style.display = index === 1 ? 'block' : 'none';} + if (element2) {element2.style.display = index === 2 ? 'block' : 'none';} + } + else { + console.error('Unable to find elements to toggle'); + } +} + // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Event handlers. @@ -712,57 +771,29 @@ function hideDeletedForms() { } })(); -// 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); - if (element1 && element2) { - // Toggle display based on the index - element1.style.display = index === 1 ? 'block' : 'none'; - element2.style.display = index === 2 ? 'block' : 'none'; - } else { - console.error('One or both elements not found.'); - } -} /** * An IIFE that listens to the other contacts radio form on DAs and toggles the contacts/no other contacts forms * */ (function otherContactsFormListener() { - // Get the radio buttons - let radioButtons = document.querySelectorAll('input[name="other_contacts-has_other_contacts"]'); - - function handleRadioButtonChange() { - // Check the value of the selected radio button - // Attempt to find the radio button element that is checked - let radioButtonChecked = document.querySelector('input[name="other_contacts-has_other_contacts"]:checked'); - - // Check if the element exists before accessing its value - let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; - - switch (selectedValue) { - case 'True': - toggleTwoDomElements('other-employees', 'no-other-employees', 1); - break; - - case 'False': - toggleTwoDomElements('other-employees', 'no-other-employees', 2); - break; - - default: - toggleTwoDomElements('other-employees', 'no-other-employees', 0); - } - } - - if (radioButtons.length) { - // Add event listener to each radio button - radioButtons.forEach(function (radioButton) { - radioButton.addEventListener('change', handleRadioButtonChange); - }); - - // initialize - handleRadioButtonChange(); - } + HookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees') })(); + +/** + * An IIFE that listens to the yes/no radio buttons on the anything else form and toggles form field visibility accordingly + * + */ +(function anythingElseFormListener() { + HookupYesNoListener("anything_else-has_anything_else_text",'anything-else', null) +})(); + + +/** + * An IIFE that listens to the yes/no radio buttons on the CISA representatives form and toggles form field visibility accordingly + * + */ +(function cisaRepresentativesFormListener() { + HookupYesNoListener("anything_else-has_cisa_representative",'cisa-representative', null) +})(); \ No newline at end of file diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 3918fa087..4cc044771 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -46,7 +46,7 @@ for step, view in [ (Step.PURPOSE, views.Purpose), (Step.YOUR_CONTACT, views.YourContact), (Step.OTHER_CONTACTS, views.OtherContacts), - (Step.ANYTHING_ELSE, views.AnythingElse), + (Step.ANYTHING_ELSE, views.AdditionalDetails), (Step.REQUIREMENTS, views.Requirements), (Step.REVIEW, views.Review), ]: diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 1efc028f6..9193015e4 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -757,28 +757,15 @@ OtherContactsFormSet = forms.formset_factory( formset=BaseOtherContactsFormSet, ) - -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=("No other employees rationale"), - widget=forms.Textarea(), - validators=[ - MaxLengthValidator( - 1000, - message="Response must be less than 1000 characters.", - ) - ], - error_messages={"required": ("Rationale for no other employees is required.")}, - ) - +class BaseDeletableRegistrarForm(RegistrarForm): + """Adds special validation and delete functionality. + Used by forms that are tied to a Yes/No form.""" def __init__(self, *args, **kwargs): self.form_data_marked_for_deletion = False super().__init__(*args, **kwargs) def mark_form_for_deletion(self): - """Marks no_other_contacts form for deletion. + """Marks this form for deletion. This changes behavior of validity checks and to_database methods.""" self.form_data_marked_for_deletion = True @@ -822,8 +809,53 @@ class NoOtherContactsForm(RegistrarForm): setattr(obj, name, value) obj.save() +class NoOtherContactsForm(BaseDeletableRegistrarForm): + no_other_contacts_rationale = forms.CharField( + required=True, + # label has to end in a space to get the label_suffix to show + label=("No other employees rationale"), + widget=forms.Textarea(), + validators=[ + MaxLengthValidator( + 1000, + message="Response must be less than 1000 characters.", + ) + ], + error_messages={"required": ("Rationale for no other employees is required.")}, + ) -class AnythingElseForm(RegistrarForm): +class CisaRepresentativeForm(BaseDeletableRegistrarForm): + cisa_representative_email = forms.EmailField( + required=False, + label="Are you working with a CISA representative?", #TODO-NL: (design check) - is this the right label? + ) + +class CisaRepresentativeYesNoForm(RegistrarForm): + def __init__(self, *args, **kwargs): + """Extend the initialization of the form from RegistrarForm __init__""" + super().__init__(*args, **kwargs) + # set the initial value based on attributes of domain request + if self.domain_request: + if self.domain_request.has_cisa_representative(): + initial_value = True + else: + initial_value = False + else: + # No pre-selection for new domain requests + initial_value = None + + self.fields["has_cisa_representative"] = forms.TypedChoiceField( + coerce=lambda x: x.lower() == "true" if x is not None else None, # coerce strings to bool, excepting None + choices=((True, "Yes"), (False, "No")), + initial=initial_value, + widget=forms.RadioSelect, + error_messages={ + "required": "This question is required.", + }, + ) + + +class AdditionalDetailsForm(BaseDeletableRegistrarForm): anything_else = forms.CharField( required=False, label="Anything else?", @@ -836,6 +868,29 @@ class AnythingElseForm(RegistrarForm): ], ) +class AdditionalDetailsYesNoForm(RegistrarForm): + def __init__(self, *args, **kwargs): + """Extend the initialization of the form from RegistrarForm __init__""" + super().__init__(*args, **kwargs) + # set the initial value based on attributes of domain request + if self.domain_request: + if self.domain_request.has_anything_else_text(): + initial_value = True + else: + initial_value = False + else: + # No pre-selection for new domain requests + initial_value = None + + self.fields["has_anything_else_text"] = forms.TypedChoiceField( + coerce=lambda x: x.lower() == "true" if x is not None else None, # coerce strings to bool, excepting None + choices=((True, "Yes"), (False, "No")), + initial=initial_value, + widget=forms.RadioSelect, + error_messages={ + "required": "This question is required.", #TODO-NL: (design check) - is this required? + }, + ) class RequirementsForm(RegistrarForm): is_policy_acknowledged = forms.BooleanField( diff --git a/src/registrar/migrations/0082_domaininformation_cisa_representative_email_and_more.py b/src/registrar/migrations/0082_domaininformation_cisa_representative_email_and_more.py new file mode 100644 index 000000000..b6be96eaa --- /dev/null +++ b/src/registrar/migrations/0082_domaininformation_cisa_representative_email_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.10 on 2024-04-10 22:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0081_create_groups_v10"), + ] + + operations = [ + migrations.AddField( + model_name="domaininformation", + name="cisa_representative_email", + field=models.EmailField(blank=True, db_index=True, max_length=254, null=True), + ), + migrations.AddField( + model_name="domainrequest", + name="cisa_representative_email", + field=models.EmailField(blank=True, db_index=True, max_length=254, null=True), + ), + ] diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index b5755a3c9..689aadc8a 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -198,6 +198,12 @@ class DomainInformation(TimeStampedModel): help_text="Anything else?", ) + cisa_representative_email = models.EmailField( + null=True, + blank=True, + db_index=True, + ) + is_policy_acknowledged = models.BooleanField( null=True, blank=True, diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index f4581de93..a9c4164d6 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -566,6 +566,12 @@ class DomainRequest(TimeStampedModel): help_text="Anything else?", ) + cisa_representative_email = models.EmailField( + null=True, + blank=True, + db_index=True, + ) + is_policy_acknowledged = models.BooleanField( null=True, blank=True, @@ -923,6 +929,14 @@ class DomainRequest(TimeStampedModel): def has_other_contacts(self) -> bool: """Does this domain request have other contacts listed?""" return self.other_contacts.exists() + + def has_anything_else_text(self) -> bool: + """Does this domain request have an 'anything else?' entry""" + return self.anything_else != "" and self.anything_else != None #TODO-NL: how to handle falsy strings again? + + def has_cisa_representative(self) -> bool: + """Does this domain request have cisa representative?""" + return self.cisa_representative_email != "" and self.cisa_representative_email != None def is_federal(self) -> Union[bool, None]: """Is this domain request for a federal agency? diff --git a/src/registrar/templates/domain_request_additional_details.html b/src/registrar/templates/domain_request_additional_details.html new file mode 100644 index 000000000..430d47472 --- /dev/null +++ b/src/registrar/templates/domain_request_additional_details.html @@ -0,0 +1,56 @@ +{% extends 'domain_request_form.html' %} +{% load static field_helpers %} + +{% block form_instructions %} +

These questions are required (*).

+{% endblock %} + +{% block form_required_fields_help_text %} +{# commented out so it does not appear at this point on this page #} +{% endblock %} + + +{% block form_fields %} +
+ +

Are you working with anyone from CISA regions on your domain request?

+

.gov is managed by the Cybersecurity and Infrastructure Security Agency. CISA has 10 regions that some organizations choose to work with. Regional representatives use titles like protective security advisors, cyber security advisors, or election security advisors.

+
+ + + {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.0.has_cisa_representative %} + {% endwith %} + {# forms.0 is a small yes/no form that toggles the visibility of "cisa representative" formset #} + +
+ +
+

Your representative’s email (*)

+ {% input_with_errors forms.1.cisa_representative_email %} + {# forms.1 is a form for inputting the e-mail of a cisa representative #} + +
+ + +
+ +

Is there anything else you’d like us to know about your domain request?

+
+ + + {% with add_class="usa-radio__input--tile" add_legend_class="usa-sr-only" %} + {% input_with_errors forms.2.has_anything_else_text %} + {% endwith %} + {# forms.2 is a small yes/no form that toggles the visibility of "cisa representative" formset #} + +
+ +
+ {% with attr_maxlength=2000 add_label_class="usa-sr-only" %} + {% input_with_errors forms.3.anything_else %} + {% endwith %} + {# forms.3 is a form for inputting the e-mail of a cisa representative #} + +
+{% endblock %} diff --git a/src/registrar/tests/test_forms.py b/src/registrar/tests/test_forms.py index 4b8904f0c..c72c70e98 100644 --- a/src/registrar/tests/test_forms.py +++ b/src/registrar/tests/test_forms.py @@ -15,7 +15,7 @@ from registrar.forms.domain_request_wizard import ( RequirementsForm, TribalGovernmentForm, PurposeForm, - AnythingElseForm, + AdditionalDetailsForm, AboutYourOrganizationForm, ) from registrar.forms.domain import ContactForm @@ -274,7 +274,7 @@ class TestFormValidation(MockEppLib): def test_anything_else_form_about_your_organization_character_count_invalid(self): """Response must be less than 2000 characters.""" - form = AnythingElseForm( + form = AdditionalDetailsForm( data={ "anything_else": "Bacon ipsum dolor amet fatback strip steak pastrami" "shankle, drumstick doner chicken landjaeger turkey andouille." diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 244b47602..9889b9b8a 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -580,10 +580,68 @@ class OtherContacts(DomainRequestWizard): all_forms_valid = False return all_forms_valid +#DONE-NL: rename this to "Additional Details" (note: this is a find-replace job. VS will not refactor properly) +class AdditionalDetails(DomainRequestWizard): -class AnythingElse(DomainRequestWizard): - template_name = "domain_request_anything_else.html" - forms = [forms.AnythingElseForm] + # TODO-NL: Delete this old (original code for anything else) + # template_name = "domain_request_anything_else.html" + # forms = [forms.AdditionalDetailsForm] + + template_name = "domain_request_additional_details.html" + # OLD: forms = [forms.OtherContactsYesNoForm, forms.OtherContactsFormSet, forms.NoOtherContactsForm] + # TODO-NL: (refactor) -- move form hookups into respective areas + forms = [forms.CisaRepresentativeYesNoForm, forms.CisaRepresentativeForm, forms.AdditionalDetailsYesNoForm, forms.AdditionalDetailsForm] + + # TODO-NL: (refactor) -- move validation into respective areas + def is_valid(self, forms: list) -> bool: + + # Validate Cisa Representative + """Overrides default behavior defined in DomainRequestWizard. + Depending on value in yes_no forms, marks corresponding data + for deletion. Then validates all forms. + """ + cisa_representative_email_yes_no_form = forms[0] + cisa_representative_email_form = forms[1] + anything_else_yes_no_form = forms[2] + anything_else_form = forms[3] + + # ------- Validate cisa representative ------- + cisa_rep_portion_is_valid = True + # test first for yes_no_form validity + if cisa_representative_email_yes_no_form.is_valid(): + # test for existing data + if not cisa_representative_email_yes_no_form.cleaned_data.get("has_cisa_representative"): + # mark the cisa_representative_email_form for deletion + cisa_representative_email_form.mark_form_for_deletion() + else: + cisa_rep_portion_is_valid = cisa_representative_email_form.is_valid() + else: + # if yes no form is invalid, no choice has been made + # mark the cisa_representative_email_form for deletion + cisa_representative_email_form.mark_form_for_deletion() + cisa_rep_portion_is_valid = False + + + # ------- Validate anything else ------- + anything_else_portion_is_valid = True + # test first for yes_no_form validity + if anything_else_yes_no_form.is_valid(): + # test for existing data + if not anything_else_yes_no_form.cleaned_data.get("has_anything_else_text"): + # mark the anything_else_form for deletion + anything_else_form.mark_form_for_deletion() + else: + anything_else_portion_is_valid = cisa_representative_email_form.is_valid() + else: + # if yes no form is invalid, no choice has been made + # mark the anything_else_form for deletion + anything_else_form.mark_form_for_deletion() + anything_else_portion_is_valid = False + + + # ------- Return combined validation result ------- + all_forms_valid = cisa_rep_portion_is_valid and anything_else_portion_is_valid + return all_forms_valid class Requirements(DomainRequestWizard):