From 24a18f1562e50d2210a72ce6916aa41d7e7c8e94 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 6 May 2024 14:19:25 -0600 Subject: [PATCH 001/214] Remove db_index, add meta index --- src/registrar/models/contact.py | 11 +++++++---- src/registrar/models/domain.py | 7 +++++++ src/registrar/models/domain_information.py | 10 ++++++++-- src/registrar/models/domain_request.py | 11 +++++++++-- src/registrar/models/transition_domain.py | 3 --- src/registrar/models/user.py | 1 - src/registrar/models/verified_by_staff.py | 1 - 7 files changed, 31 insertions(+), 13 deletions(-) diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index 3ebd8bc3e..d401102a8 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -8,6 +8,13 @@ from phonenumber_field.modelfields import PhoneNumberField # type: ignore class Contact(TimeStampedModel): """Contact information follows a similar pattern for each contact.""" + class Meta: + """Contains meta information about this class""" + indexes = [ + models.Index(fields=["user"]), + models.Index(fields=["email"]), + ] + user = models.OneToOneField( "registrar.User", null=True, @@ -19,7 +26,6 @@ class Contact(TimeStampedModel): null=True, blank=True, verbose_name="first name", - db_index=True, ) middle_name = models.CharField( null=True, @@ -29,7 +35,6 @@ class Contact(TimeStampedModel): null=True, blank=True, verbose_name="last name", - db_index=True, ) title = models.CharField( null=True, @@ -39,13 +44,11 @@ class Contact(TimeStampedModel): email = models.EmailField( null=True, blank=True, - db_index=True, max_length=320, ) phone = PhoneNumberField( null=True, blank=True, - db_index=True, ) def _get_all_relations(self): diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 7f53bb234..537735752 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -65,6 +65,13 @@ class Domain(TimeStampedModel, DomainHelper): domain meets the required checks. """ + class Meta: + """Contains meta information about this class""" + indexes = [ + models.Index(fields=["name"]), + ] + + def __init__(self, *args, **kwargs): self._cache = {} super(Domain, self).__init__(*args, **kwargs) diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index c724423ce..21fc27a0d 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -22,6 +22,14 @@ class DomainInformation(TimeStampedModel): the domain request once approved, so copying them that way we can make changes after its approved. Most fields here are copied from DomainRequest.""" + class Meta: + """Contains meta information about this class""" + indexes = [ + models.Index(fields=["domain"]), + models.Index(fields=["domain_request"]), + models.Index(fields=["generic_org_type"]), + ] + StateTerritoryChoices = DomainRequest.StateTerritoryChoices # use the short names in Django admin @@ -120,7 +128,6 @@ class DomainInformation(TimeStampedModel): organization_name = models.CharField( null=True, blank=True, - db_index=True, ) address_line1 = models.CharField( null=True, @@ -147,7 +154,6 @@ class DomainInformation(TimeStampedModel): max_length=10, null=True, blank=True, - db_index=True, verbose_name="zip code", ) urbanization = models.CharField( diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 75fbadc3e..19f1a66c1 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -24,6 +24,15 @@ logger = logging.getLogger(__name__) class DomainRequest(TimeStampedModel): """A registrant's domain request for a new domain.""" + class Meta: + """Contains meta information about this class""" + indexes = [ + models.Index(fields=["requested_domain"]), + models.Index(fields=["approved_domain"]), + models.Index(fields=["status"]), + models.Index(fields=["generic_org_type"]), + ] + # https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history # If we note any performace degradation due to this addition, # we can query the auditlogs table in admin.py and add the results to @@ -537,7 +546,6 @@ class DomainRequest(TimeStampedModel): organization_name = models.CharField( null=True, blank=True, - db_index=True, ) address_line1 = models.CharField( @@ -566,7 +574,6 @@ class DomainRequest(TimeStampedModel): null=True, blank=True, verbose_name="zip code", - db_index=True, ) urbanization = models.CharField( null=True, diff --git a/src/registrar/models/transition_domain.py b/src/registrar/models/transition_domain.py index 2dafd6da4..0b0cffcec 100644 --- a/src/registrar/models/transition_domain.py +++ b/src/registrar/models/transition_domain.py @@ -59,7 +59,6 @@ class TransitionDomain(TimeStampedModel): null=True, blank=True, help_text="Organization name", - db_index=True, ) federal_type = models.CharField( max_length=50, @@ -85,7 +84,6 @@ class TransitionDomain(TimeStampedModel): blank=True, help_text="First name / given name", verbose_name="first name", - db_index=True, ) middle_name = models.CharField( null=True, @@ -136,7 +134,6 @@ class TransitionDomain(TimeStampedModel): blank=True, verbose_name="zip code", help_text="Zip code", - db_index=True, ) def __str__(self): diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 5e4c88f63..7e82ee1c1 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -70,7 +70,6 @@ class User(AbstractUser): null=True, blank=True, help_text="Phone", - db_index=True, ) verification_type = models.CharField( diff --git a/src/registrar/models/verified_by_staff.py b/src/registrar/models/verified_by_staff.py index c09dce822..1e3e21057 100644 --- a/src/registrar/models/verified_by_staff.py +++ b/src/registrar/models/verified_by_staff.py @@ -9,7 +9,6 @@ class VerifiedByStaff(TimeStampedModel): email = models.EmailField( null=False, blank=False, - db_index=True, ) requestor = models.ForeignKey( From e265008bc86b9a1f094664494ccb4f46776dbf5e Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 6 May 2024 14:25:52 -0600 Subject: [PATCH 002/214] Add migrations --- ...email_alter_contact_first_name_and_more.py | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/registrar/migrations/0091_alter_contact_email_alter_contact_first_name_and_more.py diff --git a/src/registrar/migrations/0091_alter_contact_email_alter_contact_first_name_and_more.py b/src/registrar/migrations/0091_alter_contact_email_alter_contact_first_name_and_more.py new file mode 100644 index 000000000..a35cc6a22 --- /dev/null +++ b/src/registrar/migrations/0091_alter_contact_email_alter_contact_first_name_and_more.py @@ -0,0 +1,111 @@ +# Generated by Django 4.2.10 on 2024-05-06 20:21 + +from django.db import migrations, models +import phonenumber_field.modelfields + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0090_waffleflag"), + ] + + operations = [ + migrations.AlterField( + model_name="contact", + name="email", + field=models.EmailField(blank=True, max_length=320, null=True), + ), + migrations.AlterField( + model_name="contact", + name="first_name", + field=models.CharField(blank=True, null=True, verbose_name="first name"), + ), + migrations.AlterField( + model_name="contact", + name="last_name", + field=models.CharField(blank=True, null=True, verbose_name="last name"), + ), + migrations.AlterField( + model_name="contact", + name="phone", + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None), + ), + migrations.AlterField( + model_name="domaininformation", + name="organization_name", + field=models.CharField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domaininformation", + name="zipcode", + field=models.CharField(blank=True, max_length=10, null=True, verbose_name="zip code"), + ), + migrations.AlterField( + model_name="domainrequest", + name="organization_name", + field=models.CharField(blank=True, null=True), + ), + migrations.AlterField( + model_name="domainrequest", + name="zipcode", + field=models.CharField(blank=True, max_length=10, null=True, verbose_name="zip code"), + ), + migrations.AlterField( + model_name="transitiondomain", + name="first_name", + field=models.CharField( + blank=True, help_text="First name / given name", null=True, verbose_name="first name" + ), + ), + migrations.AlterField( + model_name="transitiondomain", + name="organization_name", + field=models.CharField(blank=True, help_text="Organization name", null=True), + ), + migrations.AlterField( + model_name="transitiondomain", + name="zipcode", + field=models.CharField(blank=True, help_text="Zip code", max_length=10, null=True, verbose_name="zip code"), + ), + migrations.AlterField( + model_name="user", + name="phone", + field=phonenumber_field.modelfields.PhoneNumberField( + blank=True, help_text="Phone", max_length=128, null=True, region=None + ), + ), + migrations.AlterField( + model_name="verifiedbystaff", + name="email", + field=models.EmailField(max_length=254), + ), + migrations.AddIndex( + model_name="contact", + index=models.Index(fields=["user"], name="registrar_c_user_id_4059c4_idx"), + ), + migrations.AddIndex( + model_name="contact", + index=models.Index(fields=["email"], name="registrar_c_email_bde2de_idx"), + ), + migrations.AddIndex( + model_name="domain", + index=models.Index(fields=["name"], name="registrar_d_name_5b1956_idx"), + ), + migrations.AddIndex( + model_name="domainrequest", + index=models.Index(fields=["requested_domain"], name="registrar_d_request_6894eb_idx"), + ), + migrations.AddIndex( + model_name="domainrequest", + index=models.Index(fields=["approved_domain"], name="registrar_d_approve_ac4c46_idx"), + ), + migrations.AddIndex( + model_name="domainrequest", + index=models.Index(fields=["status"], name="registrar_d_status_a32b59_idx"), + ), + migrations.AddIndex( + model_name="domainrequest", + index=models.Index(fields=["generic_org_type"], name="registrar_d_generic_4d1d2a_idx"), + ), + ] From b5784a1a88e6fc3ab870d6783e9798bd92ef8b8c Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 7 May 2024 10:18:21 -0600 Subject: [PATCH 003/214] Add timers --- src/registrar/admin.py | 15 ++- src/registrar/utility/csv_export.py | 190 ++++++++++++++-------------- 2 files changed, 106 insertions(+), 99 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 76d4a6909..c3ba18bbe 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -32,6 +32,7 @@ from django.contrib.auth.forms import UserChangeForm, UsernameField from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter from django.utils.translation import gettext_lazy as _ +from registrar.models.utility.generic_helper import Timer logger = logging.getLogger(__name__) @@ -2027,12 +2028,14 @@ class DomainAdmin(ListHeaderAdmin): return HttpResponseRedirect(reverse("domain", args=(obj.id,))) def change_view(self, request, object_id): - # If the analyst was recently editing a domain page, - # delete any associated session values - if "analyst_action" in request.session: - del request.session["analyst_action"] - del request.session["analyst_action_location"] - return super().change_view(request, object_id) + logger.info("Timing change_view on domain") + with Timer(): + # If the analyst was recently editing a domain page, + # delete any associated session values + if "analyst_action" in request.session: + del request.session["analyst_action"] + del request.session["analyst_action_location"] + return super().change_view(request, object_id) def has_change_permission(self, request, obj=None): # Fixes a bug wherein users which are only is_staff diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 8787f9e74..83754f574 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -13,7 +13,7 @@ from django.db.models.functions import Concat, Coalesce from registrar.models.public_contact import PublicContact from registrar.models.user_domain_role import UserDomainRole from registrar.utility.enums import DefaultEmail - +from registrar.models.utility.generic_helper import Timer logger = logging.getLogger(__name__) @@ -379,108 +379,112 @@ def write_csv_for_requests( def export_data_type_to_csv(csv_file): """All domains report with extra columns""" + logger.info("Timing export_data_type_to_csv") + with Timer(): + writer = csv.writer(csv_file) + # define columns to include in export + columns = [ + "Domain name", + "Status", + "Expiration date", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "AO", + "AO email", + "Security contact email", + # For domain manager we are pass it in as a parameter below in write_body + ] - writer = csv.writer(csv_file) - # define columns to include in export - columns = [ - "Domain name", - "Status", - "Expiration date", - "Domain type", - "Agency", - "Organization name", - "City", - "State", - "AO", - "AO email", - "Security contact email", - # For domain manager we are pass it in as a parameter below in write_body - ] - - # Coalesce is used to replace federal_type of None with ZZZZZ - sort_fields = [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", - "domain__name", - ] - filter_condition = { - "domain__state__in": [ - Domain.State.READY, - Domain.State.DNS_NEEDED, - Domain.State.ON_HOLD, - ], - } - write_csv_for_domains( - writer, columns, sort_fields, filter_condition, should_get_domain_managers=True, should_write_header=True - ) + # Coalesce is used to replace federal_type of None with ZZZZZ + sort_fields = [ + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", + "domain__name", + ] + filter_condition = { + "domain__state__in": [ + Domain.State.READY, + Domain.State.DNS_NEEDED, + Domain.State.ON_HOLD, + ], + } + write_csv_for_domains( + writer, columns, sort_fields, filter_condition, should_get_domain_managers=True, should_write_header=True + ) def export_data_full_to_csv(csv_file): """All domains report""" - writer = csv.writer(csv_file) - # define columns to include in export - columns = [ - "Domain name", - "Domain type", - "Agency", - "Organization name", - "City", - "State", - "Security contact email", - ] - # Coalesce is used to replace federal_type of None with ZZZZZ - sort_fields = [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", - "domain__name", - ] - filter_condition = { - "domain__state__in": [ - Domain.State.READY, - Domain.State.DNS_NEEDED, - Domain.State.ON_HOLD, - ], - } - write_csv_for_domains( - writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True - ) + logger.info("Timing def export_data_full_to_csv") + with Timer(): + writer = csv.writer(csv_file) + # define columns to include in export + columns = [ + "Domain name", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "Security contact email", + ] + # Coalesce is used to replace federal_type of None with ZZZZZ + sort_fields = [ + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", + "domain__name", + ] + filter_condition = { + "domain__state__in": [ + Domain.State.READY, + Domain.State.DNS_NEEDED, + Domain.State.ON_HOLD, + ], + } + write_csv_for_domains( + writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True + ) def export_data_federal_to_csv(csv_file): """Federal domains report""" - - writer = csv.writer(csv_file) - # define columns to include in export - columns = [ - "Domain name", - "Domain type", - "Agency", - "Organization name", - "City", - "State", - "Security contact email", - ] - # Coalesce is used to replace federal_type of None with ZZZZZ - sort_fields = [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", - "domain__name", - ] - filter_condition = { - "organization_type__icontains": "federal", - "domain__state__in": [ - Domain.State.READY, - Domain.State.DNS_NEEDED, - Domain.State.ON_HOLD, - ], - } - write_csv_for_domains( - writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True - ) + logger.info("Timing def export_data_federal_to_csv") + with Timer(): + writer = csv.writer(csv_file) + # define columns to include in export + columns = [ + "Domain name", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "Security contact email", + ] + # Coalesce is used to replace federal_type of None with ZZZZZ + sort_fields = [ + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", + "domain__name", + ] + filter_condition = { + "organization_type__icontains": "federal", + "domain__state__in": [ + Domain.State.READY, + Domain.State.DNS_NEEDED, + Domain.State.ON_HOLD, + ], + } + write_csv_for_domains( + writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True + ) def get_default_start_date(): From ca2e2168aa6a8116cc7e1ec818e6978476389ac1 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 7 May 2024 14:13:55 -0600 Subject: [PATCH 004/214] Remove timer --- src/registrar/admin.py | 13 +- ...email_alter_contact_first_name_and_more.py | 6 +- src/registrar/models/domain_invitation.py | 6 + src/registrar/models/user.py | 7 + src/registrar/utility/csv_export.py | 185 +++++++++--------- 5 files changed, 114 insertions(+), 103 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index c3ba18bbe..efafbcfc4 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -2029,13 +2029,12 @@ class DomainAdmin(ListHeaderAdmin): def change_view(self, request, object_id): logger.info("Timing change_view on domain") - with Timer(): - # If the analyst was recently editing a domain page, - # delete any associated session values - if "analyst_action" in request.session: - del request.session["analyst_action"] - del request.session["analyst_action_location"] - return super().change_view(request, object_id) + # If the analyst was recently editing a domain page, + # delete any associated session values + if "analyst_action" in request.session: + del request.session["analyst_action"] + del request.session["analyst_action_location"] + return super().change_view(request, object_id) def has_change_permission(self, request, obj=None): # Fixes a bug wherein users which are only is_staff diff --git a/src/registrar/migrations/0091_alter_contact_email_alter_contact_first_name_and_more.py b/src/registrar/migrations/0091_alter_contact_email_alter_contact_first_name_and_more.py index a35cc6a22..a3b8fe0e9 100644 --- a/src/registrar/migrations/0091_alter_contact_email_alter_contact_first_name_and_more.py +++ b/src/registrar/migrations/0091_alter_contact_email_alter_contact_first_name_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-05-06 20:21 +# Generated by Django 4.2.10 on 2024-05-07 20:13 from django.db import migrations, models import phonenumber_field.modelfields @@ -92,6 +92,10 @@ class Migration(migrations.Migration): model_name="domain", index=models.Index(fields=["name"], name="registrar_d_name_5b1956_idx"), ), + migrations.AddIndex( + model_name="domaininvitation", + index=models.Index(fields=["status"], name="registrar_d_status_e84571_idx"), + ), migrations.AddIndex( model_name="domainrequest", index=models.Index(fields=["requested_domain"], name="registrar_d_request_6894eb_idx"), diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py index 12082142d..39473c0be 100644 --- a/src/registrar/models/domain_invitation.py +++ b/src/registrar/models/domain_invitation.py @@ -15,6 +15,12 @@ logger = logging.getLogger(__name__) class DomainInvitation(TimeStampedModel): + class Meta: + """Contains meta information about this class""" + indexes = [ + models.Index(fields=["status"]), + ] + # Constants for status field class DomainInvitationStatus(models.TextChoices): INVITED = "invited", "Invited" diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 7e82ee1c1..e630366a4 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -24,6 +24,13 @@ class User(AbstractUser): but can be customized later. """ + class Meta: + """Contains meta information about this class""" + indexes = [ + models.Index(fields=["username"]), + models.Index(fields=["email"]), + ] + class VerificationTypeChoices(models.TextChoices): """ Users achieve access to our system in a few different ways. diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 83754f574..abd91c0ba 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -380,111 +380,106 @@ def write_csv_for_requests( def export_data_type_to_csv(csv_file): """All domains report with extra columns""" logger.info("Timing export_data_type_to_csv") - with Timer(): - writer = csv.writer(csv_file) - # define columns to include in export - columns = [ - "Domain name", - "Status", - "Expiration date", - "Domain type", - "Agency", - "Organization name", - "City", - "State", - "AO", - "AO email", - "Security contact email", - # For domain manager we are pass it in as a parameter below in write_body - ] + writer = csv.writer(csv_file) + # define columns to include in export + columns = [ + "Domain name", + "Status", + "Expiration date", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "AO", + "AO email", + "Security contact email", + # For domain manager we are pass it in as a parameter below in write_body + ] - # Coalesce is used to replace federal_type of None with ZZZZZ - sort_fields = [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", - "domain__name", - ] - filter_condition = { - "domain__state__in": [ - Domain.State.READY, - Domain.State.DNS_NEEDED, - Domain.State.ON_HOLD, - ], - } - write_csv_for_domains( - writer, columns, sort_fields, filter_condition, should_get_domain_managers=True, should_write_header=True - ) + # Coalesce is used to replace federal_type of None with ZZZZZ + sort_fields = [ + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", + "domain__name", + ] + filter_condition = { + "domain__state__in": [ + Domain.State.READY, + Domain.State.DNS_NEEDED, + Domain.State.ON_HOLD, + ], + } + write_csv_for_domains( + writer, columns, sort_fields, filter_condition, should_get_domain_managers=True, should_write_header=True + ) def export_data_full_to_csv(csv_file): """All domains report""" - logger.info("Timing def export_data_full_to_csv") - with Timer(): - writer = csv.writer(csv_file) - # define columns to include in export - columns = [ - "Domain name", - "Domain type", - "Agency", - "Organization name", - "City", - "State", - "Security contact email", - ] - # Coalesce is used to replace federal_type of None with ZZZZZ - sort_fields = [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", - "domain__name", - ] - filter_condition = { - "domain__state__in": [ - Domain.State.READY, - Domain.State.DNS_NEEDED, - Domain.State.ON_HOLD, - ], - } - write_csv_for_domains( - writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True - ) + writer = csv.writer(csv_file) + # define columns to include in export + columns = [ + "Domain name", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "Security contact email", + ] + # Coalesce is used to replace federal_type of None with ZZZZZ + sort_fields = [ + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", + "domain__name", + ] + filter_condition = { + "domain__state__in": [ + Domain.State.READY, + Domain.State.DNS_NEEDED, + Domain.State.ON_HOLD, + ], + } + write_csv_for_domains( + writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True + ) def export_data_federal_to_csv(csv_file): """Federal domains report""" - logger.info("Timing def export_data_federal_to_csv") - with Timer(): - writer = csv.writer(csv_file) - # define columns to include in export - columns = [ - "Domain name", - "Domain type", - "Agency", - "Organization name", - "City", - "State", - "Security contact email", - ] - # Coalesce is used to replace federal_type of None with ZZZZZ - sort_fields = [ - "organization_type", - Coalesce("federal_type", Value("ZZZZZ")), - "federal_agency", - "domain__name", - ] - filter_condition = { - "organization_type__icontains": "federal", - "domain__state__in": [ - Domain.State.READY, - Domain.State.DNS_NEEDED, - Domain.State.ON_HOLD, - ], - } - write_csv_for_domains( - writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True - ) + writer = csv.writer(csv_file) + # define columns to include in export + columns = [ + "Domain name", + "Domain type", + "Agency", + "Organization name", + "City", + "State", + "Security contact email", + ] + # Coalesce is used to replace federal_type of None with ZZZZZ + sort_fields = [ + "organization_type", + Coalesce("federal_type", Value("ZZZZZ")), + "federal_agency", + "domain__name", + ] + filter_condition = { + "organization_type__icontains": "federal", + "domain__state__in": [ + Domain.State.READY, + Domain.State.DNS_NEEDED, + Domain.State.ON_HOLD, + ], + } + write_csv_for_domains( + writer, columns, sort_fields, filter_condition, should_get_domain_managers=False, should_write_header=True + ) def get_default_start_date(): From af272c0cd3a4fb81fe760f3f53c965ca10bc54ae Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 7 May 2024 14:16:34 -0600 Subject: [PATCH 005/214] Fix migrations after merge --- ..._alter_contact_email_alter_contact_first_name_and_more.py} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/registrar/migrations/{0091_alter_contact_email_alter_contact_first_name_and_more.py => 0093_alter_contact_email_alter_contact_first_name_and_more.py} (96%) diff --git a/src/registrar/migrations/0091_alter_contact_email_alter_contact_first_name_and_more.py b/src/registrar/migrations/0093_alter_contact_email_alter_contact_first_name_and_more.py similarity index 96% rename from src/registrar/migrations/0091_alter_contact_email_alter_contact_first_name_and_more.py rename to src/registrar/migrations/0093_alter_contact_email_alter_contact_first_name_and_more.py index a3b8fe0e9..ca1127f2e 100644 --- a/src/registrar/migrations/0091_alter_contact_email_alter_contact_first_name_and_more.py +++ b/src/registrar/migrations/0093_alter_contact_email_alter_contact_first_name_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-05-07 20:13 +# Generated by Django 4.2.10 on 2024-05-07 20:16 from django.db import migrations, models import phonenumber_field.modelfields @@ -7,7 +7,7 @@ import phonenumber_field.modelfields class Migration(migrations.Migration): dependencies = [ - ("registrar", "0090_waffleflag"), + ("registrar", "0092_rename_updated_federal_agency_domaininformation_federal_agency_and_more"), ] operations = [ From 00bcdd16944767d34ee68096f87728de763fb2c6 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 7 May 2024 14:33:15 -0600 Subject: [PATCH 006/214] Remove timer remnants, cleanup --- src/registrar/admin.py | 3 +-- ..._contact_email_alter_contact_first_name_and_more.py | 10 +++++----- src/registrar/models/contact.py | 1 + src/registrar/models/domain.py | 3 ++- src/registrar/models/domain_information.py | 2 +- src/registrar/models/domain_invitation.py | 1 + src/registrar/models/domain_request.py | 2 +- src/registrar/models/user.py | 1 + src/registrar/utility/csv_export.py | 4 ++-- 9 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index d8b9ff9f9..55cd358c5 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -32,7 +32,7 @@ from django.contrib.auth.forms import UserChangeForm, UsernameField from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter from django.utils.translation import gettext_lazy as _ -from registrar.models.utility.generic_helper import Timer + logger = logging.getLogger(__name__) @@ -2024,7 +2024,6 @@ class DomainAdmin(ListHeaderAdmin): return HttpResponseRedirect(reverse("domain", args=(obj.id,))) def change_view(self, request, object_id): - logger.info("Timing change_view on domain") # If the analyst was recently editing a domain page, # delete any associated session values if "analyst_action" in request.session: diff --git a/src/registrar/migrations/0093_alter_contact_email_alter_contact_first_name_and_more.py b/src/registrar/migrations/0093_alter_contact_email_alter_contact_first_name_and_more.py index ca1127f2e..9696f0c50 100644 --- a/src/registrar/migrations/0093_alter_contact_email_alter_contact_first_name_and_more.py +++ b/src/registrar/migrations/0093_alter_contact_email_alter_contact_first_name_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-05-07 20:16 +# Generated by Django 4.2.10 on 2024-05-07 20:32 from django.db import migrations, models import phonenumber_field.modelfields @@ -92,6 +92,10 @@ class Migration(migrations.Migration): model_name="domain", index=models.Index(fields=["name"], name="registrar_d_name_5b1956_idx"), ), + migrations.AddIndex( + model_name="domain", + index=models.Index(fields=["state"], name="registrar_d_state_84c134_idx"), + ), migrations.AddIndex( model_name="domaininvitation", index=models.Index(fields=["status"], name="registrar_d_status_e84571_idx"), @@ -108,8 +112,4 @@ class Migration(migrations.Migration): model_name="domainrequest", index=models.Index(fields=["status"], name="registrar_d_status_a32b59_idx"), ), - migrations.AddIndex( - model_name="domainrequest", - index=models.Index(fields=["generic_org_type"], name="registrar_d_generic_4d1d2a_idx"), - ), ] diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index d401102a8..5084ea955 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -10,6 +10,7 @@ class Contact(TimeStampedModel): class Meta: """Contains meta information about this class""" + indexes = [ models.Index(fields=["user"]), models.Index(fields=["email"]), diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 537735752..fbf2822a8 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -67,11 +67,12 @@ class Domain(TimeStampedModel, DomainHelper): class Meta: """Contains meta information about this class""" + indexes = [ models.Index(fields=["name"]), + models.Index(fields=["state"]), ] - def __init__(self, *args, **kwargs): self._cache = {} super(Domain, self).__init__(*args, **kwargs) diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index e0b5ad237..81a8c7296 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -24,10 +24,10 @@ class DomainInformation(TimeStampedModel): class Meta: """Contains meta information about this class""" + indexes = [ models.Index(fields=["domain"]), models.Index(fields=["domain_request"]), - models.Index(fields=["generic_org_type"]), ] StateTerritoryChoices = DomainRequest.StateTerritoryChoices diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py index 39473c0be..c9cbc8b39 100644 --- a/src/registrar/models/domain_invitation.py +++ b/src/registrar/models/domain_invitation.py @@ -17,6 +17,7 @@ logger = logging.getLogger(__name__) class DomainInvitation(TimeStampedModel): class Meta: """Contains meta information about this class""" + indexes = [ models.Index(fields=["status"]), ] diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index a947aba2f..02e30b266 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -26,11 +26,11 @@ class DomainRequest(TimeStampedModel): class Meta: """Contains meta information about this class""" + indexes = [ models.Index(fields=["requested_domain"]), models.Index(fields=["approved_domain"]), models.Index(fields=["status"]), - models.Index(fields=["generic_org_type"]), ] # https://django-auditlog.readthedocs.io/en/latest/usage.html#object-history diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index e630366a4..1ff700239 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -26,6 +26,7 @@ class User(AbstractUser): class Meta: """Contains meta information about this class""" + indexes = [ models.Index(fields=["username"]), models.Index(fields=["email"]), diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index abd91c0ba..19d66e598 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -13,7 +13,8 @@ from django.db.models.functions import Concat, Coalesce from registrar.models.public_contact import PublicContact from registrar.models.user_domain_role import UserDomainRole from registrar.utility.enums import DefaultEmail -from registrar.models.utility.generic_helper import Timer + + logger = logging.getLogger(__name__) @@ -379,7 +380,6 @@ def write_csv_for_requests( def export_data_type_to_csv(csv_file): """All domains report with extra columns""" - logger.info("Timing export_data_type_to_csv") writer = csv.writer(csv_file) # define columns to include in export columns = [ From 87aa44cc7788880df863b0ec4685d560224ba098 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 7 May 2024 15:17:40 -0600 Subject: [PATCH 007/214] Cleanup --- src/registrar/admin.py | 1 - ..._email_alter_contact_first_name_and_more.py | 18 +++++++++++++++++- src/registrar/models/domain_information.py | 5 ++--- src/registrar/models/user.py | 13 +++++-------- src/registrar/utility/csv_export.py | 1 - 5 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 55cd358c5..3eea86871 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -33,7 +33,6 @@ from django_admin_multiple_choice_list_filter.list_filters import MultipleChoice from django.utils.translation import gettext_lazy as _ - logger = logging.getLogger(__name__) diff --git a/src/registrar/migrations/0093_alter_contact_email_alter_contact_first_name_and_more.py b/src/registrar/migrations/0093_alter_contact_email_alter_contact_first_name_and_more.py index 9696f0c50..3c45c4ef2 100644 --- a/src/registrar/migrations/0093_alter_contact_email_alter_contact_first_name_and_more.py +++ b/src/registrar/migrations/0093_alter_contact_email_alter_contact_first_name_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.10 on 2024-05-07 20:32 +# Generated by Django 4.2.10 on 2024-05-07 21:17 from django.db import migrations, models import phonenumber_field.modelfields @@ -96,6 +96,14 @@ class Migration(migrations.Migration): model_name="domain", index=models.Index(fields=["state"], name="registrar_d_state_84c134_idx"), ), + migrations.AddIndex( + model_name="domaininformation", + index=models.Index(fields=["domain"], name="registrar_d_domain__88838a_idx"), + ), + migrations.AddIndex( + model_name="domaininformation", + index=models.Index(fields=["domain_request"], name="registrar_d_domain__d1fba8_idx"), + ), migrations.AddIndex( model_name="domaininvitation", index=models.Index(fields=["status"], name="registrar_d_status_e84571_idx"), @@ -112,4 +120,12 @@ class Migration(migrations.Migration): model_name="domainrequest", index=models.Index(fields=["status"], name="registrar_d_status_a32b59_idx"), ), + migrations.AddIndex( + model_name="user", + index=models.Index(fields=["username"], name="registrar_u_usernam_964b1b_idx"), + ), + migrations.AddIndex( + model_name="user", + index=models.Index(fields=["email"], name="registrar_u_email_c8f2c4_idx"), + ), ] diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index 81a8c7296..23c9e4f32 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -30,6 +30,8 @@ class DomainInformation(TimeStampedModel): models.Index(fields=["domain_request"]), ] + verbose_name_plural = "Domain information" + StateTerritoryChoices = DomainRequest.StateTerritoryChoices # use the short names in Django admin @@ -342,6 +344,3 @@ class DomainInformation(TimeStampedModel): def _get_many_to_many_fields(): """Returns a set of each field.name that has the many to many relation""" return {field.name for field in DomainInformation._meta.many_to_many} # type: ignore - - class Meta: - verbose_name_plural = "Domain information" diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 1ff700239..307a7cbcc 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -25,13 +25,16 @@ class User(AbstractUser): """ class Meta: - """Contains meta information about this class""" - indexes = [ models.Index(fields=["username"]), models.Index(fields=["email"]), ] + permissions = [ + ("analyst_access_permission", "Analyst Access Permission"), + ("full_access_permission", "Full Access Permission"), + ] + class VerificationTypeChoices(models.TextChoices): """ Users achieve access to our system in a few different ways. @@ -252,9 +255,3 @@ class User(AbstractUser): """ self.check_domain_invitations_on_login() - - class Meta: - permissions = [ - ("analyst_access_permission", "Analyst Access Permission"), - ("full_access_permission", "Full Access Permission"), - ] diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 19d66e598..9ac83ed86 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -14,7 +14,6 @@ from registrar.models.public_contact import PublicContact from registrar.models.user_domain_role import UserDomainRole from registrar.utility.enums import DefaultEmail - logger = logging.getLogger(__name__) From d268ef54b19e108bbdf8c40080fe4ca6cbe94b22 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 9 May 2024 11:42:18 -0600 Subject: [PATCH 008/214] Basic setup stuff --- src/djangooidc/backends.py | 10 ++++-- src/djangooidc/tests/test_backends.py | 10 +++--- src/djangooidc/views.py | 13 ++++++-- .../migrations/0094_user_finished_setup.py | 18 ++++++++++ src/registrar/models/user.py | 7 ++++ src/registrar/views/domain_request.py | 9 ++++- src/registrar/views/utility/mixins.py | 33 +++++++++++++++++++ .../views/utility/permission_views.py | 23 +++++++++++-- 8 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 src/registrar/migrations/0094_user_finished_setup.py diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py index 41e442f2d..8bdd44698 100644 --- a/src/djangooidc/backends.py +++ b/src/djangooidc/backends.py @@ -21,10 +21,13 @@ class OpenIdConnectBackend(ModelBackend): """ def authenticate(self, request, **kwargs): + """Returns a tuple of (User, is_new_user)""" logger.debug("kwargs %s" % kwargs) user = None + is_new_user = True + if not kwargs or "sub" not in kwargs.keys(): - return user + return user, is_new_user UserModel = get_user_model() username = self.clean_username(kwargs["sub"]) @@ -48,6 +51,7 @@ class OpenIdConnectBackend(ModelBackend): } user, created = UserModel.objects.get_or_create(**args) + is_new_user = created if not created: # If user exists, update existing user @@ -59,10 +63,10 @@ class OpenIdConnectBackend(ModelBackend): try: user = UserModel.objects.get_by_natural_key(username) except UserModel.DoesNotExist: - return None + return None, is_new_user # run this callback for a each login user.on_each_login() - return user + return user, is_new_user def update_existing_user(self, user, kwargs): """ diff --git a/src/djangooidc/tests/test_backends.py b/src/djangooidc/tests/test_backends.py index c15106fa9..7b7b963ea 100644 --- a/src/djangooidc/tests/test_backends.py +++ b/src/djangooidc/tests/test_backends.py @@ -21,7 +21,7 @@ class OpenIdConnectBackendTestCase(TestCase): """Test that authenticate creates a new user if it does not find existing user""" # Ensure that the authenticate method creates a new user - user = self.backend.authenticate(request=None, **self.kwargs) + user, _ = self.backend.authenticate(request=None, **self.kwargs) self.assertIsNotNone(user) self.assertIsInstance(user, User) self.assertEqual(user.username, "test_user") @@ -39,7 +39,7 @@ class OpenIdConnectBackendTestCase(TestCase): existing_user = User.objects.create_user(username="test_user") # Ensure that the authenticate method updates the existing user - user = self.backend.authenticate(request=None, **self.kwargs) + user, _ = self.backend.authenticate(request=None, **self.kwargs) self.assertIsNotNone(user) self.assertIsInstance(user, User) self.assertEqual(user, existing_user) # The same user instance should be returned @@ -68,7 +68,7 @@ class OpenIdConnectBackendTestCase(TestCase): # Ensure that the authenticate method updates the existing user # and preserves existing first and last names - user = self.backend.authenticate(request=None, **self.kwargs) + user, _ = self.backend.authenticate(request=None, **self.kwargs) self.assertIsNotNone(user) self.assertIsInstance(user, User) self.assertEqual(user, existing_user) # The same user instance should be returned @@ -89,7 +89,7 @@ class OpenIdConnectBackendTestCase(TestCase): # Ensure that the authenticate method updates the existing user # and preserves existing first and last names - user = self.backend.authenticate(request=None, **self.kwargs) + user, _ = self.backend.authenticate(request=None, **self.kwargs) self.assertIsNotNone(user) self.assertIsInstance(user, User) self.assertEqual(user, existing_user) # The same user instance should be returned @@ -103,5 +103,5 @@ class OpenIdConnectBackendTestCase(TestCase): def test_authenticate_with_unknown_user(self): """Test that authenticate returns None when no kwargs are supplied""" # Ensure that the authenticate method handles the case when the user is not found - user = self.backend.authenticate(request=None, **{}) + user, _ = self.backend.authenticate(request=None, **{}) self.assertIsNone(user) diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index 815df4ecf..c7a8f1bba 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -85,6 +85,7 @@ def login_callback(request): """Analyze the token returned by the authentication provider (OP).""" global CLIENT try: + request.session["is_new_user"] = False # If the CLIENT is none, attempt to reinitialize before handling the request if _client_is_none(): logger.debug("OIDC client is None, attempting to initialize") @@ -97,9 +98,9 @@ def login_callback(request): # add acr_value to request.session request.session["acr_value"] = CLIENT.get_step_up_acr_value() return CLIENT.create_authn_request(request.session) - user = authenticate(request=request, **userinfo) + user, is_new_user = authenticate(request=request, **userinfo) if user: - + should_update_user = False # Fixture users kind of exist in a superposition of verification types, # because while the system "verified" them, if they login, # we don't know how the user themselves was verified through login.gov until @@ -110,9 +111,17 @@ def login_callback(request): # Set the verification type if it doesn't already exist or if its a fixture user if not user.verification_type or is_fixture_user: user.set_user_verification_type() + should_update_user = True + + if is_new_user: + user.finished_setup = False + should_update_user = True + + if should_update_user: user.save() login(request, user) + logger.info("Successfully logged in user %s" % user) # Clear the flag if the exception is not caught diff --git a/src/registrar/migrations/0094_user_finished_setup.py b/src/registrar/migrations/0094_user_finished_setup.py new file mode 100644 index 000000000..660f950c0 --- /dev/null +++ b/src/registrar/migrations/0094_user_finished_setup.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.10 on 2024-05-09 17:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0093_alter_publiccontact_unique_together"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="finished_setup", + field=models.BooleanField(default=True), + ), + ] diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 5e4c88f63..b3fd95eb3 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -80,6 +80,13 @@ class User(AbstractUser): help_text="The means through which this user was verified", ) + # Tracks if the user finished their profile setup or not. This is so + # we can globally enforce that new users provide additional context before proceeding. + finished_setup = models.BooleanField( + # Default to true so we don't impact existing users. We set this to false downstream. + default=True + ) + def __str__(self): # this info is pulled from Login.gov if self.first_name or self.last_name: diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index f93976138..6b0ef7223 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -14,7 +14,7 @@ from registrar.models.contact import Contact from registrar.models.user import User from registrar.utility import StrEnum from registrar.views.utility import StepsHelper -from registrar.views.utility.permission_views import DomainRequestPermissionDeleteView +from registrar.views.utility.permission_views import DomainRequestPermissionDeleteView, ContactPermissionView from .utility import ( DomainRequestPermissionView, @@ -819,3 +819,10 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView): duplicates = [item for item, count in object_dict.items() if count > 1] return duplicates + + +class FinishContactProfileSetupView(ContactPermissionView): + """This view forces the user into providing additional details that + we may have missed from Login.gov""" + template_name = "domain_request_your_contact.html" + forms = [forms.YourContactForm] \ No newline at end of file diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index c7083ce48..4fdba113d 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -8,6 +8,7 @@ from registrar.models import ( DomainInvitation, DomainInformation, UserDomainRole, + Contact, ) import logging @@ -324,6 +325,38 @@ class UserDeleteDomainRolePermission(PermissionsLoginMixin): return True +class ContactPermission(PermissionsLoginMixin): + """Permission mixin for UserDomainRole if user + has access, otherwise 403""" + + def has_permission(self): + """Check if this user has access to this domain request. + + The user is in self.request.user and the domain needs to be looked + up from the domain's primary key in self.kwargs["pk"] + """ + + # Check if the user is authenticated + if not self.request.user.is_authenticated: + return False + + user_pk = self.kwargs["pk"] + + # Check if the user has an associated contact + associated_contacts = Contact.objects.filter(user=user_pk) + associated_contacts_length = len(associated_contacts) + + if associated_contacts_length == 0: + # This means that the user trying to access this page + # is a different user than the contact holder. + return False + elif associated_contacts_length > 1: + # TODO - change this + raise ValueError("User has multiple connected contacts") + else: + return True + + class DomainRequestPermissionWithdraw(PermissionsLoginMixin): """Permission mixin that redirects to withdraw action on domain request if user has access, otherwise 403""" diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index f2752c3b5..c626367fe 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -3,8 +3,7 @@ import abc # abstract base class from django.views.generic import DetailView, DeleteView, TemplateView -from registrar.models import Domain, DomainRequest, DomainInvitation -from registrar.models.user_domain_role import UserDomainRole +from registrar.models import Domain, DomainRequest, DomainInvitation, UserDomainRole, Contact from .mixins import ( DomainPermission, @@ -13,6 +12,7 @@ from .mixins import ( DomainInvitationPermission, DomainRequestWizardPermission, UserDeleteDomainRolePermission, + ContactPermission, ) import logging @@ -142,3 +142,22 @@ class UserDomainRolePermissionDeleteView(UserDeleteDomainRolePermission, DeleteV # variable name in template context for the model object context_object_name = "userdomainrole" + + +class ContactPermissionView(ContactPermission, DetailView, abc.ABC): + """Abstract base view for domain requests that enforces permissions + + This abstract view cannot be instantiated. Actual views must specify + `template_name`. + """ + + # DetailView property for what model this is viewing + model = Contact + # variable name in template context for the model object + context_object_name = "Contact" + + # Abstract property enforces NotImplementedError on an attribute. + @property + @abc.abstractmethod + def template_name(self): + raise NotImplementedError \ No newline at end of file From dda620ee4d648777652890f5b2cb28abc185de05 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 9 May 2024 12:09:23 -0600 Subject: [PATCH 009/214] Add finished setup flag --- src/djangooidc/backends.py | 12 +++++------- src/djangooidc/tests/test_backends.py | 10 +++++----- src/djangooidc/views.py | 3 ++- src/registrar/admin.py | 4 ++-- 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py index 8bdd44698..2de6adc3e 100644 --- a/src/djangooidc/backends.py +++ b/src/djangooidc/backends.py @@ -21,13 +21,11 @@ class OpenIdConnectBackend(ModelBackend): """ def authenticate(self, request, **kwargs): - """Returns a tuple of (User, is_new_user)""" logger.debug("kwargs %s" % kwargs) user = None - is_new_user = True - + request.session["is_new_user"] = True if not kwargs or "sub" not in kwargs.keys(): - return user, is_new_user + return user UserModel = get_user_model() username = self.clean_username(kwargs["sub"]) @@ -51,7 +49,7 @@ class OpenIdConnectBackend(ModelBackend): } user, created = UserModel.objects.get_or_create(**args) - is_new_user = created + request.session["is_new_user"] = created if not created: # If user exists, update existing user @@ -63,10 +61,10 @@ class OpenIdConnectBackend(ModelBackend): try: user = UserModel.objects.get_by_natural_key(username) except UserModel.DoesNotExist: - return None, is_new_user + return None # run this callback for a each login user.on_each_login() - return user, is_new_user + return user def update_existing_user(self, user, kwargs): """ diff --git a/src/djangooidc/tests/test_backends.py b/src/djangooidc/tests/test_backends.py index 7b7b963ea..c15106fa9 100644 --- a/src/djangooidc/tests/test_backends.py +++ b/src/djangooidc/tests/test_backends.py @@ -21,7 +21,7 @@ class OpenIdConnectBackendTestCase(TestCase): """Test that authenticate creates a new user if it does not find existing user""" # Ensure that the authenticate method creates a new user - user, _ = self.backend.authenticate(request=None, **self.kwargs) + user = self.backend.authenticate(request=None, **self.kwargs) self.assertIsNotNone(user) self.assertIsInstance(user, User) self.assertEqual(user.username, "test_user") @@ -39,7 +39,7 @@ class OpenIdConnectBackendTestCase(TestCase): existing_user = User.objects.create_user(username="test_user") # Ensure that the authenticate method updates the existing user - user, _ = self.backend.authenticate(request=None, **self.kwargs) + user = self.backend.authenticate(request=None, **self.kwargs) self.assertIsNotNone(user) self.assertIsInstance(user, User) self.assertEqual(user, existing_user) # The same user instance should be returned @@ -68,7 +68,7 @@ class OpenIdConnectBackendTestCase(TestCase): # Ensure that the authenticate method updates the existing user # and preserves existing first and last names - user, _ = self.backend.authenticate(request=None, **self.kwargs) + user = self.backend.authenticate(request=None, **self.kwargs) self.assertIsNotNone(user) self.assertIsInstance(user, User) self.assertEqual(user, existing_user) # The same user instance should be returned @@ -89,7 +89,7 @@ class OpenIdConnectBackendTestCase(TestCase): # Ensure that the authenticate method updates the existing user # and preserves existing first and last names - user, _ = self.backend.authenticate(request=None, **self.kwargs) + user = self.backend.authenticate(request=None, **self.kwargs) self.assertIsNotNone(user) self.assertIsInstance(user, User) self.assertEqual(user, existing_user) # The same user instance should be returned @@ -103,5 +103,5 @@ class OpenIdConnectBackendTestCase(TestCase): def test_authenticate_with_unknown_user(self): """Test that authenticate returns None when no kwargs are supplied""" # Ensure that the authenticate method handles the case when the user is not found - user, _ = self.backend.authenticate(request=None, **{}) + user = self.backend.authenticate(request=None, **{}) self.assertIsNone(user) diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index c7a8f1bba..4b111f130 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -98,7 +98,8 @@ def login_callback(request): # add acr_value to request.session request.session["acr_value"] = CLIENT.get_step_up_acr_value() return CLIENT.create_authn_request(request.session) - user, is_new_user = authenticate(request=request, **userinfo) + user = authenticate(request=request, **userinfo) + is_new_user = request.session["is_new_user"] if user: should_update_user = False # Fixture users kind of exist in a superposition of verification types, diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 3eea86871..a81e5e414 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -539,7 +539,7 @@ class MyUserAdmin(BaseUserAdmin): fieldsets = ( ( None, - {"fields": ("username", "password", "status", "verification_type")}, + {"fields": ("username", "password", "status", "finished_setup", "verification_type")}, ), ("Personal Info", {"fields": ("first_name", "last_name", "email")}), ( @@ -557,7 +557,7 @@ class MyUserAdmin(BaseUserAdmin): ("Important dates", {"fields": ("last_login", "date_joined")}), ) - readonly_fields = ("verification_type",) + readonly_fields = ("verification_type", "finished_setup") # Hide Username (uuid), Groups and Permissions # Q: Now that we're using Groups and Permissions, From 2f36033eb27f9bd2383f77d33735e8e2bb8663d1 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 9 May 2024 12:13:01 -0600 Subject: [PATCH 010/214] User setup stuff --- src/djangooidc/backends.py | 1 + src/djangooidc/views.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py index 2de6adc3e..3f5c1022e 100644 --- a/src/djangooidc/backends.py +++ b/src/djangooidc/backends.py @@ -60,6 +60,7 @@ class OpenIdConnectBackend(ModelBackend): else: try: user = UserModel.objects.get_by_natural_key(username) + request.session["is_new_user"] = False except UserModel.DoesNotExist: return None # run this callback for a each login diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index 4b111f130..0eaf28f01 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -114,7 +114,9 @@ def login_callback(request): user.set_user_verification_type() should_update_user = True - if is_new_user: + # If we're dealing with a new user and if this field isn't set already, + # Then set this to False. Otherwise, if we set the field manually it'll revert. + if is_new_user and not user.finished_setup: user.finished_setup = False should_update_user = True From 84408fce4888a9d6dec516cf7c2b97598f3351d0 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 9 May 2024 12:45:21 -0600 Subject: [PATCH 011/214] Add perms checks --- src/djangooidc/views.py | 4 +++- src/registrar/config/urls.py | 7 +++++++ .../templates/finish_contact_setup.html | 7 +++++++ src/registrar/views/domain_request.py | 4 ++-- src/registrar/views/utility/mixins.py | 17 +++++++++++++++-- 5 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 src/registrar/templates/finish_contact_setup.html diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index 0eaf28f01..c58c3a0aa 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -129,7 +129,9 @@ def login_callback(request): # Clear the flag if the exception is not caught request.session.pop("redirect_attempted", None) - return redirect(request.session.get("next", "/")) + + success_redirect_url = "/" if user.finished_setup else f"/finish-user-setup/{user.id}" + return redirect(request.session.get("next", success_redirect_url)) else: raise o_e.BannedUser() except o_e.StateMismatch as nsd_err: diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 720034150..2c6942ca8 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -100,6 +100,13 @@ urlpatterns = [ name="analytics", ), path("admin/", admin.site.urls), + path( + # We embed the current user ID here, but we have a permission check + # that ensures the user is who they say they are. + "finish-user-setup/", + views.FinishContactProfileSetupView.as_view(), + name="finish-contact-profile-setup", + ), path( "domain-request//edit/", views.DomainRequestWizard.as_view(), diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html new file mode 100644 index 000000000..930eb4a23 --- /dev/null +++ b/src/registrar/templates/finish_contact_setup.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% load static url_helpers %} +{% block title %} Finish setting up your profile {% endblock %} + +{% block content %} +

TEST

+{% endblock content %} diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 6b0ef7223..ee97dddf9 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -824,5 +824,5 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView): class FinishContactProfileSetupView(ContactPermissionView): """This view forces the user into providing additional details that we may have missed from Login.gov""" - template_name = "domain_request_your_contact.html" - forms = [forms.YourContactForm] \ No newline at end of file + template_name = "finish_contact_setup.html" + forms = [forms.YourContactForm] diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 4fdba113d..45c7c7860 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -9,6 +9,7 @@ from registrar.models import ( DomainInformation, UserDomainRole, Contact, + User, ) import logging @@ -340,10 +341,22 @@ class ContactPermission(PermissionsLoginMixin): if not self.request.user.is_authenticated: return False - user_pk = self.kwargs["pk"] + + given_user_pk = self.kwargs["pk"] + + # Grab the user in the DB to do a full object comparision, not just on ids + current_user = self.request.user + + # Check for the ids existence since we're dealing with requests + requested_user_exists = User.objects.filter(pk=given_user_pk).exists() + + # Compare the PK that was passed in to the user currently logged in + if current_user.pk != given_user_pk and requested_user_exists: + # Don't allow users to modify other users profiles + return False # Check if the user has an associated contact - associated_contacts = Contact.objects.filter(user=user_pk) + associated_contacts = Contact.objects.filter(user=current_user) associated_contacts_length = len(associated_contacts) if associated_contacts_length == 0: From 4c92011279a03dd2e6b9b5aa5fbbed4da0297a75 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 9 May 2024 13:04:54 -0600 Subject: [PATCH 012/214] Add some middleware --- src/djangooidc/views.py | 3 +- src/registrar/config/settings.py | 3 +- src/registrar/no_cache_middleware.py | 17 ---------- src/registrar/registrar_middleware.py | 46 +++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 20 deletions(-) delete mode 100644 src/registrar/no_cache_middleware.py create mode 100644 src/registrar/registrar_middleware.py diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index c58c3a0aa..3716ebf19 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -130,8 +130,7 @@ def login_callback(request): # Clear the flag if the exception is not caught request.session.pop("redirect_attempted", None) - success_redirect_url = "/" if user.finished_setup else f"/finish-user-setup/{user.id}" - return redirect(request.session.get("next", success_redirect_url)) + return redirect(request.session.get("next", "/")) else: raise o_e.BannedUser() except o_e.StateMismatch as nsd_err: diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index bbf06b825..d0849e222 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -160,7 +160,7 @@ MIDDLEWARE = [ # django-cors-headers: listen to cors responses "corsheaders.middleware.CorsMiddleware", # custom middleware to stop caching from CloudFront - "registrar.no_cache_middleware.NoCacheMiddleware", + "registrar.registrar_middleware.NoCacheMiddleware", # serve static assets in production "whitenoise.middleware.WhiteNoiseMiddleware", # provide security enhancements to the request/response cycle @@ -186,6 +186,7 @@ MIDDLEWARE = [ "auditlog.middleware.AuditlogMiddleware", # Used for waffle feature flags "waffle.middleware.WaffleMiddleware", + "registrar.registrar_middleware.CheckUserProfileMiddleware", ] # application object used by Django’s built-in servers (e.g. `runserver`) diff --git a/src/registrar/no_cache_middleware.py b/src/registrar/no_cache_middleware.py deleted file mode 100644 index 5edfca20e..000000000 --- a/src/registrar/no_cache_middleware.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Middleware to add Cache-control: no-cache to every response. - -Used to force Cloudfront caching to leave us alone while we develop -better caching responses. -""" - - -class NoCacheMiddleware: - """Middleware to add a single header to every response.""" - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - response = self.get_response(request) - response["Cache-Control"] = "no-cache" - return response diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py new file mode 100644 index 000000000..0054f9158 --- /dev/null +++ b/src/registrar/registrar_middleware.py @@ -0,0 +1,46 @@ +""" +Contains middleware used in settings.py +""" + +from django.urls import reverse +from django.http import HttpResponseRedirect + +class CheckUserProfileMiddleware: + """ + Checks if the current user has finished_setup = False. + If they do, redirect them to the setup page regardless of where they are in + the application. + """ + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + """Code that gets executed on each request before the view is called""" + response = self.get_response(request) + return response + + def process_view(self, request, view_func, view_args, view_kwargs): + # Check if the user is authenticated and if the setup is not finished + if request.user.is_authenticated and not request.user.finished_setup: + # Redirect to the setup page + return HttpResponseRedirect(reverse('finish-contact-profile-setup')) + + # Continue processing the view + return None + + +class NoCacheMiddleware: + """ + Middleware to add Cache-control: no-cache to every response. + + Used to force Cloudfront caching to leave us alone while we develop + better caching responses. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + response["Cache-Control"] = "no-cache" + return response \ No newline at end of file From 8b41e70840bd6de3def2fd0c91cd62c1f8a0e315 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 9 May 2024 13:43:32 -0600 Subject: [PATCH 013/214] Fine tuning --- src/djangooidc/backends.py | 8 +++++--- src/djangooidc/views.py | 6 ++---- src/registrar/registrar_middleware.py | 18 ++++++++++++++---- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py index 3f5c1022e..96b7a902a 100644 --- a/src/djangooidc/backends.py +++ b/src/djangooidc/backends.py @@ -23,7 +23,7 @@ class OpenIdConnectBackend(ModelBackend): def authenticate(self, request, **kwargs): logger.debug("kwargs %s" % kwargs) user = None - request.session["is_new_user"] = True + if not kwargs or "sub" not in kwargs.keys(): return user @@ -49,7 +49,9 @@ class OpenIdConnectBackend(ModelBackend): } user, created = UserModel.objects.get_or_create(**args) - request.session["is_new_user"] = created + + if created: + request.session["is_new_user"] = True if not created: # If user exists, update existing user @@ -60,8 +62,8 @@ class OpenIdConnectBackend(ModelBackend): else: try: user = UserModel.objects.get_by_natural_key(username) - request.session["is_new_user"] = False except UserModel.DoesNotExist: + request.session["is_new_user"] = True return None # run this callback for a each login user.on_each_login() diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index 3716ebf19..7b5c58527 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -99,7 +99,7 @@ def login_callback(request): request.session["acr_value"] = CLIENT.get_step_up_acr_value() return CLIENT.create_authn_request(request.session) user = authenticate(request=request, **userinfo) - is_new_user = request.session["is_new_user"] + is_new_user = request.session.get("is_new_user", False) if user: should_update_user = False # Fixture users kind of exist in a superposition of verification types, @@ -114,9 +114,7 @@ def login_callback(request): user.set_user_verification_type() should_update_user = True - # If we're dealing with a new user and if this field isn't set already, - # Then set this to False. Otherwise, if we set the field manually it'll revert. - if is_new_user and not user.finished_setup: + if is_new_user: user.finished_setup = False should_update_user = True diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 0054f9158..064757d80 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -20,10 +20,20 @@ class CheckUserProfileMiddleware: return response def process_view(self, request, view_func, view_args, view_kwargs): - # Check if the user is authenticated and if the setup is not finished - if request.user.is_authenticated and not request.user.finished_setup: - # Redirect to the setup page - return HttpResponseRedirect(reverse('finish-contact-profile-setup')) + # Check if setup is not finished + finished_setup = hasattr(request.user, "finished_setup") and request.user.finished_setup + if request.user.is_authenticated and not finished_setup: + setup_page = reverse("finish-contact-profile-setup", kwargs={'pk': request.user.pk}) + logout_page = reverse("logout") + excluded_pages = [ + setup_page, + logout_page, + ] + + # Don't redirect on excluded pages (such as the setup page itself) + if not any(request.path.startswith(page) for page in excluded_pages): + # Redirect to the setup page + return HttpResponseRedirect(setup_page) # Continue processing the view return None From 75499337e056bc1e2cf4998e2d45ee195328bfa8 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 9 May 2024 15:56:18 -0600 Subject: [PATCH 014/214] Initial architecture --- src/registrar/config/urls.py | 2 +- src/registrar/fixtures_users.py | 10 +- src/registrar/forms/contact.py | 42 +++++++ src/registrar/forms/domain.py | 2 +- src/registrar/forms/domain_request_wizard.py | 37 +----- .../forms/utility/wizard_form_helper.py | 7 +- src/registrar/models/contact.py | 2 - .../templates/finish_contact_setup.html | 69 ++++++++++- src/registrar/views/__init__.py | 3 + src/registrar/views/contact.py | 107 ++++++++++++++++++ src/registrar/views/domain_request.py | 8 +- .../views/utility/permission_views.py | 10 +- 12 files changed, 238 insertions(+), 61 deletions(-) create mode 100644 src/registrar/forms/contact.py create mode 100644 src/registrar/views/contact.py diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 2c6942ca8..596e5c3d2 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -104,7 +104,7 @@ urlpatterns = [ # We embed the current user ID here, but we have a permission check # that ensures the user is who they say they are. "finish-user-setup/", - views.FinishContactProfileSetupView.as_view(), + views.ContactProfileSetupView.as_view(), name="finish-contact-profile-setup", ), path( diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index c31acacfd..d87438bc9 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -126,11 +126,11 @@ class UserFixture: "last_name": "Osos-Analyst", "email": "kosos@truss.works", }, - { - "username": "2cc0cde8-8313-4a50-99d8-5882e71443e8", - "first_name": "Zander-Analyst", - "last_name": "Adkinson-Analyst", - }, + # { + # "username": "2cc0cde8-8313-4a50-99d8-5882e71443e8", + # "first_name": "Zander-Analyst", + # "last_name": "Adkinson-Analyst", + # }, { "username": "57ab5847-7789-49fe-a2f9-21d38076d699", "first_name": "Paul-Analyst", diff --git a/src/registrar/forms/contact.py b/src/registrar/forms/contact.py new file mode 100644 index 000000000..ae8d28dc8 --- /dev/null +++ b/src/registrar/forms/contact.py @@ -0,0 +1,42 @@ +from django import forms +from phonenumber_field.modelfields import PhoneNumberField # type: ignore +from django.core.validators import MaxLengthValidator + + +class ContactForm(forms.Form): + """Form for adding or editing a contact""" + + first_name = forms.CharField( + label="First name / given name", + error_messages={"required": "Enter your first name / given name."}, + ) + middle_name = forms.CharField( + required=False, + label="Middle name (optional)", + ) + last_name = forms.CharField( + label="Last name / family name", + error_messages={"required": "Enter your last name / family name."}, + ) + title = forms.CharField( + label="Title or role in your organization", + error_messages={ + "required": ("Enter your title or role in your organization (e.g., Chief Information Officer).") + }, + ) + email = forms.EmailField( + label="Email", + max_length=None, + error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")}, + validators=[ + MaxLengthValidator( + 320, + message="Response must be less than 320 characters.", + ) + ], + ) + phone = PhoneNumberField( + label="Phone", + error_messages={"invalid": "Enter a valid 10-digit phone number.", "required": "Enter your phone number."}, + ) + diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index da1462bdb..9dfd9773a 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -202,7 +202,7 @@ NameserverFormset = formset_factory( validate_max=True, ) - +# TODO - refactor, wait until daves PR class ContactForm(forms.ModelForm): """Form for updating contacts.""" diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 9d16a30de..32c59620d 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -16,6 +16,7 @@ from registrar.forms.utility.wizard_form_helper import ( from registrar.models import Contact, DomainRequest, DraftDomain, Domain, FederalAgency from registrar.templatetags.url_helpers import public_site_url from registrar.utility.enums import ValidationReturnType +from registrar.forms import ContactForm logger = logging.getLogger(__name__) @@ -385,7 +386,7 @@ class PurposeForm(RegistrarForm): ) -class YourContactForm(RegistrarForm): +class YourContactForm(RegistrarForm, ContactForm): JOIN = "submitter" def to_database(self, obj): @@ -408,40 +409,6 @@ class YourContactForm(RegistrarForm): contact = getattr(obj, "submitter", None) return super().from_database(contact) - first_name = forms.CharField( - label="First name / given name", - error_messages={"required": "Enter your first name / given name."}, - ) - middle_name = forms.CharField( - required=False, - label="Middle name (optional)", - ) - last_name = forms.CharField( - label="Last name / family name", - error_messages={"required": "Enter your last name / family name."}, - ) - title = forms.CharField( - label="Title or role in your organization", - error_messages={ - "required": ("Enter your title or role in your organization (e.g., Chief Information Officer).") - }, - ) - email = forms.EmailField( - label="Email", - max_length=None, - error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")}, - validators=[ - MaxLengthValidator( - 320, - message="Response must be less than 320 characters.", - ) - ], - ) - phone = PhoneNumberField( - label="Phone", - error_messages={"invalid": "Enter a valid 10-digit phone number.", "required": "Enter your phone number."}, - ) - class OtherContactsYesNoForm(BaseYesNoForm): """The yes/no field for the OtherContacts form.""" diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index 2ae50f908..350605c1a 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -21,7 +21,12 @@ class RegistrarForm(forms.Form): def __init__(self, *args, **kwargs): kwargs.setdefault("label_suffix", "") # save a reference to a domain request object - self.domain_request = kwargs.pop("domain_request", None) + if "domain_request" in kwargs: + self.domain_request = kwargs.pop("domain_request", None) + + if "contact" in kwargs: + self.contact = kwargs.pop("contact", None) + super(RegistrarForm, self).__init__(*args, **kwargs) def to_database(self, obj: DomainRequest | Contact): diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index 3ebd8bc3e..3fdfccb64 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -1,10 +1,8 @@ from django.db import models from .utility.time_stamped_model import TimeStampedModel - from phonenumber_field.modelfields import PhoneNumberField # type: ignore - class Contact(TimeStampedModel): """Contact information follows a similar pattern for each contact.""" diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index 930eb4a23..c6f02f64b 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -1,7 +1,72 @@ {% extends "base.html" %} -{% load static url_helpers %} +{% load static form_helpers url_helpers field_helpers %} {% block title %} Finish setting up your profile {% endblock %} {% block content %} -

TEST

+
+
+
+
+ {% include "includes/form_messages.html" %} + {% comment %} + Repurposed from domain_request_form.html + {% endcomment %} + {% for outer in forms %} + {% if outer|isformset %} + {% for inner in outer.forms %} + {% include "includes/form_errors.html" with form=inner %} + {% endfor %} + {% else %} + {% include "includes/form_errors.html" with form=outer %} + {% endif %} + {% endfor %} + +

Finish setting up your profile

+ +

+ We require that you maintain accurate contact information. + The details you provide will only be used to support the administration of .gov and won’t be made public. +

+ +

What contact information should we use to reach you?

+

+ Review the details below and update any required information. + Note that editing this information won’t affect your Login.gov account information. +

+ {# TODO: maybe remove this? #} +

Required information is marked with an asterisk (*).

+
+ {% csrf_token %} +
+ + Your contact information + + + {% input_with_errors forms.0.first_name %} + + {% input_with_errors forms.0.middle_name %} + + {% input_with_errors forms.0.last_name %} + + {% input_with_errors forms.0.title %} + + {% input_with_errors forms.0.email %} + + {% with add_class="usa-input--medium" %} + {% input_with_errors forms.0.phone %} + {% endwith %} + +
+
+ +
+ {% block form_fields %}{% endblock %} +
+
+
+
+ {% endblock content %} + diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index bd15196d4..692cfd4de 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -14,5 +14,8 @@ from .domain import ( DomainInvitationDeleteView, DomainDeleteUserView, ) +from .contact import ( + ContactProfileSetupView, +) from .health import * from .index import * diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py new file mode 100644 index 000000000..9f0e4e393 --- /dev/null +++ b/src/registrar/views/contact.py @@ -0,0 +1,107 @@ +from registrar.forms.contact import ContactForm +from registrar.views.utility.permission_views import ContactPermissionView +from django.views.generic.edit import FormMixin + + +# TODO we can and probably should generalize this at this rate. +class BaseContactView(ContactPermissionView): + def get(self, request, *args, **kwargs): + self._set_contact(request) + context = self.get_context_data(object=self.object) + return self.render_to_response(context) + + # TODO - this deserves a small refactor + def _set_contact(self, request): + """ + get domain from session cache or from db and set + to self.object + set session to self for downstream functions to + update session cache + """ + self.session = request.session + + contact_pk = "contact:" + str(self.kwargs.get("pk")) + cached_contact = self.session.get(contact_pk) + + if cached_contact: + self.object = cached_contact + else: + self.object = self.get_object() + self._update_session_with_contact() + + def _update_session_with_contact(self): + """ + Set contact pk in the session cache + """ + domain_pk = "contact:" + str(self.kwargs.get("pk")) + self.session[domain_pk] = self.object + + +class ContactFormBaseView(BaseContactView, FormMixin): + def post(self, request, *args, **kwargs): + """Form submission posts to this view. + + This post method harmonizes using BaseContactView and FormMixin + """ + # Set the current contact object in cache + self._set_contact(request) + + # Get the current form and validate it + form = self.get_form() + return self.check_form(form) + + # TODO rename? + def check_form(self, form): + return self.form_valid(form) if form.is_valid() else self.form_invalid(form) + + def form_valid(self, form): + # updates session cache with contact + self._update_session_with_contact() + + # superclass has the redirect + return super().form_valid(form) + + def form_invalid(self, form): + # updates session cache with contact + self._update_session_with_contact() + + # superclass has the redirect + return super().form_invalid(form) + + +class ContactProfileSetupView(ContactPermissionView): + """This view forces the user into providing additional details that + we may have missed from Login.gov""" + template_name = "finish_contact_setup.html" + form_class = ContactForm + + def get(self, request, *args, **kwargs): + self._get_contact(request) + context = self.get_context_data(object=self.object) + return self.render_to_response(context) + + def _get_contact(self, request): + """ + get domain from session cache or from db and set + to self.object + set session to self for downstream functions to + update session cache + """ + self.session = request.session + + contact_pk = "contact:" + str(self.kwargs.get("pk")) + cached_contact = self.session.get(contact_pk) + + if cached_contact: + self.object = cached_contact + else: + self.object = self.get_object() + self._set_session_contact_pk() + + def _set_session_contact_pk(self): + """ + Set contact pk in the session cache + """ + domain_pk = "contact:" + str(self.kwargs.get("pk")) + self.session[domain_pk] = self.object + diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index ee97dddf9..b07f0d53f 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -14,7 +14,7 @@ from registrar.models.contact import Contact from registrar.models.user import User from registrar.utility import StrEnum from registrar.views.utility import StepsHelper -from registrar.views.utility.permission_views import DomainRequestPermissionDeleteView, ContactPermissionView +from registrar.views.utility.permission_views import DomainRequestPermissionDeleteView from .utility import ( DomainRequestPermissionView, @@ -820,9 +820,3 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView): duplicates = [item for item, count in object_dict.items() if count > 1] return duplicates - -class FinishContactProfileSetupView(ContactPermissionView): - """This view forces the user into providing additional details that - we may have missed from Login.gov""" - template_name = "finish_contact_setup.html" - forms = [forms.YourContactForm] diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index c626367fe..5587d2d56 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -153,11 +153,7 @@ class ContactPermissionView(ContactPermission, DetailView, abc.ABC): # DetailView property for what model this is viewing model = Contact - # variable name in template context for the model object - context_object_name = "Contact" + object: Contact - # Abstract property enforces NotImplementedError on an attribute. - @property - @abc.abstractmethod - def template_name(self): - raise NotImplementedError \ No newline at end of file + # variable name in template context for the model object + context_object_name = "contact" From dd9df90fb4f9c377532bd0c9b614812b2fdbfd5b Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 10 May 2024 08:33:10 -0600 Subject: [PATCH 015/214] Infra --- src/registrar/forms/contact.py | 6 ++- src/registrar/forms/domain_request_wizard.py | 37 ++++++++++++++++++- .../forms/utility/wizard_form_helper.py | 3 -- .../templates/finish_contact_setup.html | 16 ++++---- src/registrar/views/contact.py | 6 +++ 5 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/registrar/forms/contact.py b/src/registrar/forms/contact.py index ae8d28dc8..1ddc1a2a0 100644 --- a/src/registrar/forms/contact.py +++ b/src/registrar/forms/contact.py @@ -1,11 +1,15 @@ from django import forms -from phonenumber_field.modelfields import PhoneNumberField # type: ignore +from phonenumber_field.formfields import PhoneNumberField # type: ignore from django.core.validators import MaxLengthValidator class ContactForm(forms.Form): """Form for adding or editing a contact""" + def __init__(self, *args, **kwargs): + kwargs.setdefault("label_suffix", "") + super(ContactForm, self).__init__(*args, **kwargs) + first_name = forms.CharField( label="First name / given name", error_messages={"required": "Enter your first name / given name."}, diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 32c59620d..9d16a30de 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -16,7 +16,6 @@ from registrar.forms.utility.wizard_form_helper import ( from registrar.models import Contact, DomainRequest, DraftDomain, Domain, FederalAgency from registrar.templatetags.url_helpers import public_site_url from registrar.utility.enums import ValidationReturnType -from registrar.forms import ContactForm logger = logging.getLogger(__name__) @@ -386,7 +385,7 @@ class PurposeForm(RegistrarForm): ) -class YourContactForm(RegistrarForm, ContactForm): +class YourContactForm(RegistrarForm): JOIN = "submitter" def to_database(self, obj): @@ -409,6 +408,40 @@ class YourContactForm(RegistrarForm, ContactForm): contact = getattr(obj, "submitter", None) return super().from_database(contact) + first_name = forms.CharField( + label="First name / given name", + error_messages={"required": "Enter your first name / given name."}, + ) + middle_name = forms.CharField( + required=False, + label="Middle name (optional)", + ) + last_name = forms.CharField( + label="Last name / family name", + error_messages={"required": "Enter your last name / family name."}, + ) + title = forms.CharField( + label="Title or role in your organization", + error_messages={ + "required": ("Enter your title or role in your organization (e.g., Chief Information Officer).") + }, + ) + email = forms.EmailField( + label="Email", + max_length=None, + error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")}, + validators=[ + MaxLengthValidator( + 320, + message="Response must be less than 320 characters.", + ) + ], + ) + phone = PhoneNumberField( + label="Phone", + error_messages={"invalid": "Enter a valid 10-digit phone number.", "required": "Enter your phone number."}, + ) + class OtherContactsYesNoForm(BaseYesNoForm): """The yes/no field for the OtherContacts form.""" diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index 350605c1a..9b8a7c4d8 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -23,9 +23,6 @@ class RegistrarForm(forms.Form): # save a reference to a domain request object if "domain_request" in kwargs: self.domain_request = kwargs.pop("domain_request", None) - - if "contact" in kwargs: - self.contact = kwargs.pop("contact", None) super(RegistrarForm, self).__init__(*args, **kwargs) diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index c6f02f64b..1849889c8 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -41,20 +41,22 @@ Your contact information - - {% input_with_errors forms.0.first_name %} + {{form.first_name}} + {% comment %} + {% input_with_errors form.first_name %} - {% input_with_errors forms.0.middle_name %} + {% input_with_errors form.middle_name %} - {% input_with_errors forms.0.last_name %} + {% input_with_errors form.last_name %} - {% input_with_errors forms.0.title %} + {% input_with_errors form.title %} - {% input_with_errors forms.0.email %} + {% input_with_errors form.email %} {% with add_class="usa-input--medium" %} - {% input_with_errors forms.0.phone %} + {% input_with_errors form.phone %} {% endwith %} + {% endcomment %}
diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index 9f0e4e393..110ee254f 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -75,6 +75,12 @@ class ContactProfileSetupView(ContactPermissionView): template_name = "finish_contact_setup.html" form_class = ContactForm + def get_form_kwargs(self, *args, **kwargs): + """Add domain_info.organization_name instance to make a bound form.""" + form_kwargs = super().get_form_kwargs(*args, **kwargs) + form_kwargs["instance"] = self.object + return form_kwargs + def get(self, request, *args, **kwargs): self._get_contact(request) context = self.get_context_data(object=self.object) From a55f3391682b1e79d663b23d2a6dcb34e337cf91 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 10 May 2024 09:54:38 -0600 Subject: [PATCH 016/214] Hook form to db --- src/registrar/forms/contact.py | 21 ++++++- src/registrar/registrar_middleware.py | 2 +- .../templates/finish_contact_setup.html | 4 +- src/registrar/views/contact.py | 61 +++++++------------ 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/src/registrar/forms/contact.py b/src/registrar/forms/contact.py index 1ddc1a2a0..d699087c9 100644 --- a/src/registrar/forms/contact.py +++ b/src/registrar/forms/contact.py @@ -6,9 +6,24 @@ from django.core.validators import MaxLengthValidator class ContactForm(forms.Form): """Form for adding or editing a contact""" - def __init__(self, *args, **kwargs): - kwargs.setdefault("label_suffix", "") - super(ContactForm, self).__init__(*args, **kwargs) + def to_database(self, obj): + """ + Adds this form's cleaned data to `obj` and saves `obj`. + + Does nothing if form is not valid. + """ + if not self.is_valid(): + return + for name, value in self.cleaned_data.items(): + setattr(obj, name, value) + obj.save() + + @classmethod + def from_database(cls, obj): + """Returns a dict of form field values gotten from `obj`.""" + if obj is None: + return {} + return {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore first_name = forms.CharField( label="First name / given name", diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 064757d80..783951279 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -23,7 +23,7 @@ class CheckUserProfileMiddleware: # Check if setup is not finished finished_setup = hasattr(request.user, "finished_setup") and request.user.finished_setup if request.user.is_authenticated and not finished_setup: - setup_page = reverse("finish-contact-profile-setup", kwargs={'pk': request.user.pk}) + setup_page = reverse("finish-contact-profile-setup", kwargs={'pk': request.user.contact.pk}) logout_page = reverse("logout") excluded_pages = [ setup_page, diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index 1849889c8..1d04c6a8f 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -42,7 +42,7 @@ Your contact information {{form.first_name}} - {% comment %} + {% input_with_errors form.first_name %} {% input_with_errors form.middle_name %} @@ -56,7 +56,7 @@ {% with add_class="usa-input--medium" %} {% input_with_errors form.phone %} {% endwith %} - {% endcomment %} +
diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index 110ee254f..c640c9150 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -1,10 +1,13 @@ +from django.urls import reverse from registrar.forms.contact import ContactForm +from registrar.models.contact import Contact from registrar.views.utility.permission_views import ContactPermissionView from django.views.generic.edit import FormMixin # TODO we can and probably should generalize this at this rate. class BaseContactView(ContactPermissionView): + def get(self, request, *args, **kwargs): self._set_contact(request) context = self.get_context_data(object=self.object) @@ -38,6 +41,7 @@ class BaseContactView(ContactPermissionView): class ContactFormBaseView(BaseContactView, FormMixin): + def post(self, request, *args, **kwargs): """Form submission posts to this view. @@ -54,13 +58,6 @@ class ContactFormBaseView(BaseContactView, FormMixin): def check_form(self, form): return self.form_valid(form) if form.is_valid() else self.form_invalid(form) - def form_valid(self, form): - # updates session cache with contact - self._update_session_with_contact() - - # superclass has the redirect - return super().form_valid(form) - def form_invalid(self, form): # updates session cache with contact self._update_session_with_contact() @@ -69,45 +66,33 @@ class ContactFormBaseView(BaseContactView, FormMixin): return super().form_invalid(form) -class ContactProfileSetupView(ContactPermissionView): +class ContactProfileSetupView(ContactFormBaseView): """This view forces the user into providing additional details that we may have missed from Login.gov""" template_name = "finish_contact_setup.html" form_class = ContactForm + model = Contact - def get_form_kwargs(self, *args, **kwargs): - """Add domain_info.organization_name instance to make a bound form.""" - form_kwargs = super().get_form_kwargs(*args, **kwargs) - form_kwargs["instance"] = self.object - return form_kwargs + def get_success_url(self): + """Redirect to the nameservers page for the domain.""" + # TODO - some logic should exist that navigates them to the domain request page if + # they clicked it on get.gov - def get(self, request, *args, **kwargs): - self._get_contact(request) - context = self.get_context_data(object=self.object) - return self.render_to_response(context) + # The user has finished their setup - def _get_contact(self, request): - """ - get domain from session cache or from db and set - to self.object - set session to self for downstream functions to - update session cache - """ - self.session = request.session - contact_pk = "contact:" + str(self.kwargs.get("pk")) - cached_contact = self.session.get(contact_pk) + # Add a notification that the update was successful + return reverse("home") - if cached_contact: - self.object = cached_contact - else: - self.object = self.get_object() - self._set_session_contact_pk() + def form_valid(self, form): + self.request.user.finished_setup = True + self.request.user.save() - def _set_session_contact_pk(self): - """ - Set contact pk in the session cache - """ - domain_pk = "contact:" + str(self.kwargs.get("pk")) - self.session[domain_pk] = self.object + form.to_database(self.object) + self._update_session_with_contact() + return super().form_valid(form) + + def get_initial(self): + """The initial value for the form (which is a formset here).""" + return self.form_class.from_database(self.object) From ebf13280444cfdd61da45835b1c9ee48fd925722 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 10 May 2024 11:26:42 -0600 Subject: [PATCH 017/214] Template logic --- src/registrar/assets/sass/_theme/_base.scss | 17 ++++++++ src/registrar/forms/contact.py | 10 +---- .../templates/finish_contact_setup.html | 40 ++++++++++++------- .../templates/includes/input_with_errors.html | 36 +++++++++++++++-- src/registrar/templatetags/field_helpers.py | 9 +++++ 5 files changed, 85 insertions(+), 27 deletions(-) diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index 212df992f..fc132ebaa 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -140,3 +140,20 @@ abbr[title] { .cursor-pointer { cursor: pointer; } + +.input-with-edit-button { + svg.usa-icon { + width: 1.5em !important; + height: 1.5em !important; + // TODO CHANGE + color: green; + position: absolute; + } + &.input-with-edit-button__error { + // TODO CHANGE + svg.usa-icon { + color: red; + } + } + +} diff --git a/src/registrar/forms/contact.py b/src/registrar/forms/contact.py index d699087c9..e2a47f56c 100644 --- a/src/registrar/forms/contact.py +++ b/src/registrar/forms/contact.py @@ -44,15 +44,9 @@ class ContactForm(forms.Form): }, ) email = forms.EmailField( - label="Email", + label="Organization email", max_length=None, - error_messages={"invalid": ("Enter your email address in the required format, like name@example.com.")}, - validators=[ - MaxLengthValidator( - 320, - message="Response must be less than 320 characters.", - ) - ], + required=False, ) phone = PhoneNumberField( label="Phone", diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index 1d04c6a8f..e825dc90b 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -41,23 +41,33 @@ Your contact information - {{form.first_name}} - - {% input_with_errors form.first_name %} - - {% input_with_errors form.middle_name %} - - {% input_with_errors form.last_name %} - - {% input_with_errors form.title %} - - {% input_with_errors form.email %} - - {% with add_class="usa-input--medium" %} - {% input_with_errors form.phone %} + + {% with show_edit_button=True %} + {% input_with_errors form.first_name %} + {% endwith %} + + {% with show_edit_button=True %} + {% input_with_errors form.middle_name %} + {% endwith %} + + {% with show_edit_button=True %} + {% input_with_errors form.last_name %} + {% endwith %} + + {% with show_edit_button=True %} + {% input_with_errors form.email %} + {% endwith %} + + {% with show_edit_button=True %} + {% input_with_errors form.title %} + {% endwith %} + + {% with show_edit_button=True %} + {% with add_class="usa-input--medium" %} + {% input_with_errors form.phone %} + {% endwith %} {% endwith %} -
+
+
+ {% else %} + {% include "django/forms/label.html" %} + {% endif %} {% endif %} {% if sublabel_text %} @@ -58,9 +69,26 @@ error messages, if necessary. {% if append_gov %}
{% endif %} - {# this is the input field, itself #} - {% include widget.template_name %} + {% if show_edit_button %} +
+ +
+ {{ field.value }} +
+
+ {# this is the input field, itself #} + {% include widget.template_name %} + {% else %} + {# this is the input field, itself #} + {% include widget.template_name %} + {% endif %} {% if append_gov %} .gov
diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index 811897908..a7aa9d663 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -26,6 +26,7 @@ def input_with_errors(context, field=None): # noqa: C901 add_group_class: append to input element's surrounding tag's `class` attribute attr_* - adds or replaces any single html attribute for the input add_error_attr_* - like `attr_*` but only if field.errors is not empty + show_edit_button: shows a simple edit button, and adds display-none to the input field. Example usage: ``` @@ -91,6 +92,14 @@ def input_with_errors(context, field=None): # noqa: C901 elif key == "add_group_class": group_classes.append(value) + elif key == "show_edit_button": + # Hide the primary input field. + # Used such that we can toggle it with JS + if "display-none" not in classes and isinstance(value, bool) and value: + classes.append("display-none") + # Set this as a context value so we know what we're going to display + context["show_edit_button"] = value + attrs["id"] = field.auto_id # do some work for various edge cases From 3a47a7a61c71a01ebc656789ce106b7aafef4ba0 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Fri, 10 May 2024 13:08:57 -0600 Subject: [PATCH 018/214] Refinement --- src/registrar/assets/js/get-gov.js | 32 +++++++++++++++++++ src/registrar/assets/sass/_theme/_base.scss | 12 ++++++- src/registrar/assets/sass/_theme/_forms.scss | 17 ++++++++++ src/registrar/forms/contact.py | 5 +++ src/registrar/models/contact.py | 12 +++++++ .../templates/finish_contact_setup.html | 18 +++++++---- .../templates/includes/input_with_errors.html | 11 +++++-- src/registrar/views/contact.py | 10 +++--- 8 files changed, 101 insertions(+), 16 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index e7260ee21..6aaca61a9 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -834,3 +834,35 @@ function hideDeletedForms() { (function cisaRepresentativesFormListener() { HookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null) })(); + + + +/** + * An IIFE that hooks up the edit buttons on the finish-user-setup page + */ +(function finishUserSetupListener() { + function showInputFieldHideReadonlyField(inputField, readonlyField, editButton) { + readonlyField.classList.add('display-none'); + inputField.classList.remove('display-none'); + editButton.classList.add('display-none'); + } + + document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { + let fieldIdParts = button.id.split("__") + + if (fieldIdParts && fieldIdParts.length > 0){ + let fieldId = fieldIdParts[0] + button.addEventListener('click', function() { + // Lock the edit button while this operation occurs + button.disabled = true + + inputField = document.querySelector(`#id_${fieldId}`) + readonlyField = document.querySelector(`#${fieldId}__edit-button-readonly`) + showInputFieldHideReadonlyField(inputField, readonlyField, button) + + // Unlock after it completes + button.disabled = false + }); + } + }); +})(); \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index fc132ebaa..7677c8ffe 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -141,6 +141,7 @@ abbr[title] { cursor: pointer; } +// todo this class should ideally be renamed .input-with-edit-button { svg.usa-icon { width: 1.5em !important; @@ -154,6 +155,15 @@ abbr[title] { svg.usa-icon { color: red; } + div.readonly-field { + color: red + } + } +} + +.input-with-edit-button--button { + svg { + width: 1.25em !important; + height: 1.25em !important; } - } diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index 058a9f6c8..2766f596c 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -26,6 +26,23 @@ } } +.usa-form--edit-button-form { + margin-top: 0.5em; + // todo update + border-top: 2px black solid; + label.usa-label { + font-weight: bold; + } +} + +.usa-form--edit-button-form:first-of-type { + border-top: None +} + +.usa-form--edit-button-form > .usa-form-group:first-of-type { + margin-top: unset; +} + .usa-form-group--unstyled-error { margin-left: 0; padding-left: 0; diff --git a/src/registrar/forms/contact.py b/src/registrar/forms/contact.py index e2a47f56c..9d7d6a641 100644 --- a/src/registrar/forms/contact.py +++ b/src/registrar/forms/contact.py @@ -25,6 +25,11 @@ class ContactForm(forms.Form): return {} return {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore + + full_name = forms.CharField( + label="Full name", + error_messages={"required": "Enter your full name"}, + ) first_name = forms.CharField( label="First name / given name", error_messages={"required": "Enter your first name / given name."}, diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index 3fdfccb64..69d28df52 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -126,3 +126,15 @@ class Contact(TimeStampedModel): return str(self.pk) else: return "" + + @property + def full_name(self, separator=" "): + """ + Returns the full name (first_name, middle_name, last_name) of this contact. + Seperator (which defaults to a blank space) determines the seperator for each of those fields. + For instance, with seperator=", " - this function would return this: + "First, Middle, Last" + """ + # Filter out empty strings to avoid extra spaces or separators + parts = [self.first_name or "", self.middle_name or "", self.last_name or ""] + return separator.join(parts) \ No newline at end of file diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index e825dc90b..4892a64b7 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -42,27 +42,33 @@ Your contact information - {% with show_edit_button=True %} + {# TODO: if an error is thrown here or edit clicked, show first last and middle fields #} + {# Also todo: consolidate all of the scattered classes into this usa form one #} + {% with show_edit_button=True group_classes="usa-form--edit-button-form" %} + {% input_with_errors form.full_name %} + {% endwith %} + + {% with show_edit_button=True group_classes="usa-form--edit-button-form display-none" %} {% input_with_errors form.first_name %} {% endwith %} - {% with show_edit_button=True %} + {% with show_edit_button=True group_classes="usa-form--edit-button-form display-none" %} {% input_with_errors form.middle_name %} {% endwith %} - {% with show_edit_button=True %} + {% with show_edit_button=True group_classes="usa-form--edit-button-form display-none"%} {% input_with_errors form.last_name %} {% endwith %} - {% with show_edit_button=True %} + {% with show_edit_button=True group_classes="usa-form--edit-button-form" %} {% input_with_errors form.email %} {% endwith %} - {% with show_edit_button=True %} + {% with show_edit_button=True group_classes="usa-form--edit-button-form" %} {% input_with_errors form.title %} {% endwith %} - {% with show_edit_button=True %} + {% with show_edit_button=True group_classes="usa-form--edit-button-form" %} {% with add_class="usa-input--medium" %} {% input_with_errors form.phone %} {% endwith %} diff --git a/src/registrar/templates/includes/input_with_errors.html b/src/registrar/templates/includes/input_with_errors.html index 9303fb61d..d1dc38bc3 100644 --- a/src/registrar/templates/includes/input_with_errors.html +++ b/src/registrar/templates/includes/input_with_errors.html @@ -32,8 +32,13 @@ error messages, if necessary.
{% include "django/forms/label.html" %}
-
- +
+
{% else %} @@ -71,7 +76,7 @@ error messages, if necessary. {% endif %} {% if show_edit_button %} -
+

Finish setting up your profile

- We require that you maintain accurate contact information. + {% public_site_url 'help/account-management/#get-help-with-login.gov' %} + We require + that you maintain accurate contact information. The details you provide will only be used to support the administration of .gov and won’t be made public.

@@ -44,31 +46,32 @@ {# TODO: if an error is thrown here or edit clicked, show first last and middle fields #} {# Also todo: consolidate all of the scattered classes into this usa form one #} - {% with show_edit_button=True group_classes="usa-form--edit-button-form" %} + {% with show_edit_button=True show_readonly=True group_classes="usa-form-readonly padding-top-2" %} {% input_with_errors form.full_name %} {% endwith %} - {% with show_edit_button=True group_classes="usa-form--edit-button-form display-none" %} + {% with show_edit_button=True show_readonly=True group_classes="usa-form-readonly padding-top-2 display-none" %} {% input_with_errors form.first_name %} {% endwith %} - {% with show_edit_button=True group_classes="usa-form--edit-button-form display-none" %} + {% with show_edit_button=True show_readonly=True group_classes="usa-form-readonly padding-top-2 display-none" %} {% input_with_errors form.middle_name %} {% endwith %} - {% with show_edit_button=True group_classes="usa-form--edit-button-form display-none"%} + {% with show_edit_button=True show_readonly=True group_classes="usa-form-readonly padding-top-2 display-none"%} {% input_with_errors form.last_name %} {% endwith %} - - {% with show_edit_button=True group_classes="usa-form--edit-button-form" %} + + {# TODO: I shouldnt need to do add_class here #} + {% with show_readonly=True add_class="display-none" group_classes="usa-form-readonly padding-top-2" sublabel_text=email_sublabel_text %} {% input_with_errors form.email %} {% endwith %} - {% with show_edit_button=True group_classes="usa-form--edit-button-form" %} + {% with show_edit_button=True show_readonly=True group_classes="usa-form-readonly padding-top-2" %} {% input_with_errors form.title %} {% endwith %} - {% with show_edit_button=True group_classes="usa-form--edit-button-form" %} + {% with show_edit_button=True show_readonly=True group_classes="usa-form-readonly padding-top-2" %} {% with add_class="usa-input--medium" %} {% input_with_errors form.phone %} {% endwith %} diff --git a/src/registrar/templates/includes/input_with_errors.html b/src/registrar/templates/includes/input_with_errors.html index d1dc38bc3..43e90dca6 100644 --- a/src/registrar/templates/includes/input_with_errors.html +++ b/src/registrar/templates/includes/input_with_errors.html @@ -28,19 +28,7 @@ error messages, if necessary. {% if not field.widget_type == "checkbox" %} {% if show_edit_button %} -
-
- {% include "django/forms/label.html" %} -
-
- -
-
+ {% include "includes/label_with_edit_button.html" %} {% else %} {% include "django/forms/label.html" %} {% endif %} @@ -75,25 +63,13 @@ error messages, if necessary.
{% endif %} - {% if show_edit_button %} -
- -
- {{ field.value }} -
-
- {# this is the input field, itself #} - {% include widget.template_name %} - {% else %} - {# this is the input field, itself #} - {% include widget.template_name %} + {% if show_readonly %} + {% include "includes/readonly_input.html" %} {% endif %} + + {# this is the input field, itself #} + {% include widget.template_name %} + {% if append_gov %} .gov
diff --git a/src/registrar/templates/includes/label_with_edit_button.html b/src/registrar/templates/includes/label_with_edit_button.html new file mode 100644 index 000000000..bbff26400 --- /dev/null +++ b/src/registrar/templates/includes/label_with_edit_button.html @@ -0,0 +1,15 @@ + +{% load static field_helpers url_helpers %} +
+
+ {% include "django/forms/label.html" %} +
+
+ +
+
\ No newline at end of file diff --git a/src/registrar/templates/includes/readonly_input.html b/src/registrar/templates/includes/readonly_input.html new file mode 100644 index 000000000..820d4b66c --- /dev/null +++ b/src/registrar/templates/includes/readonly_input.html @@ -0,0 +1,14 @@ +{% load static field_helpers url_helpers %} + +
+ +
+ {{ field.value }} +
+
diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index 3c269587b..f70f59a26 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -94,3 +94,11 @@ class ContactProfileSetupView(ContactFormBaseView): """The initial value for the form (which is a formset here).""" db_object = self.form_class.from_database(self.object) return db_object + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["email_sublabel_text"] = ( + "We recommend using your work email for your .gov account. " + "If the wrong email is displayed below, you’ll need to update your Login.gov account " + "and log back in. Get help with your Login.gov account.") + return context From 7ec39e7d13343b8e63383903307c5c8c814ae689 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 13 May 2024 10:32:32 -0600 Subject: [PATCH 021/214] Match colors, and js functionality --- src/registrar/assets/js/get-gov.js | 75 +++++++++++++------ src/registrar/assets/sass/_theme/_base.scss | 9 +-- src/registrar/assets/sass/_theme/_forms.scss | 3 +- .../templates/finish_contact_setup.html | 2 +- .../templates/includes/input_with_errors.html | 2 +- .../includes/label_with_edit_button.html | 2 +- src/registrar/views/utility/mixins.py | 17 +++-- 7 files changed, 72 insertions(+), 38 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 18ab1ebce..99fd9b3a1 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -841,6 +841,8 @@ function hideDeletedForms() { * An IIFE that hooks up the edit buttons on the finish-user-setup page */ (function finishUserSetupListener() { + + // Shows the hidden input field and hides the readonly one function showInputFieldHideReadonlyField(fieldName, button) { let inputId = getInputFieldId(fieldName) let inputField = document.querySelector(inputId) @@ -885,35 +887,60 @@ function hideDeletedForms() { } } }); - - inputFieldParentDiv = inputField.closest("div"); - if (inputFieldParentDiv) { - inputFieldParentDiv.remove(); - } } } - document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { - let fieldIdParts = button.id.split("__") + function handleEditButtonClick(fieldName, button){ + button.addEventListener('click', function() { + // Lock the edit button while this operation occurs + button.disabled = true - if (fieldIdParts && fieldIdParts.length > 0){ - let fieldName = fieldIdParts[0] - button.addEventListener('click', function() { - // Lock the edit button while this operation occurs - button.disabled = true + if (fieldName == "full_name"){ + let nameFields = ["first_name", "middle_name", "last_name"] + handleFullNameField(fieldName, nameFields); + }else { + showInputFieldHideReadonlyField(fieldName, button); + } - if (fieldName == "full_name"){ - let nameFields = ["first_name", "middle_name", "last_name"] - handleFullNameField(fieldName, nameFields); - }else { - showInputFieldHideReadonlyField(fieldName, button); - } + button.classList.add('display-none'); + + // Unlock after it completes + button.disabled = false + }); + } + + function setupListener(){ + document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { + // Get the "{field_name}" and "edit-button" + let fieldIdParts = button.id.split("__") + if (fieldIdParts && fieldIdParts.length > 0){ + let fieldName = fieldIdParts[0] + + // When the edit button is clicked, show the input field under it + handleEditButtonClick(fieldName, button); + } + }); + } + + function showInputOnErrorFields(){ + document.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { + let fieldIdParts = button.id.split("__") + if (fieldIdParts && fieldIdParts.length > 0){ + let fieldName = fieldIdParts[0] - button.classList.add('display-none'); + let errorMessage = document.querySelector(`#id_${fieldName}__error-message`); + if (errorMessage) { + button.click() + } + } + }); + }); + } - // Unlock after it completes - button.disabled = false - }); - } - }); + // Hookup all edit buttons to the `handleEditButtonClick` function + setupListener(); + + // Show the input fields if an error exists + showInputOnErrorFields(); })(); \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index bfdd99fc2..beb63cdd1 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -1,4 +1,5 @@ @use "uswds-core" as *; +@use "cisa_colors" as *; /* Styles for making visible to screen reader / AT users only. */ .sr-only { @@ -146,17 +147,15 @@ abbr[title] { svg.usa-icon { width: 1.5em !important; height: 1.5em !important; - // TODO CHANGE - color: green; + color: #{$dhs-green}; position: absolute; } &.input-with-edit-button__error { - // TODO CHANGE svg.usa-icon { - color: red; + color: #{$dhs-red}; } div.readonly-field { - color: red; + color: #{$dhs-red}; } } } diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index bf1f81113..a4194273d 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -1,4 +1,5 @@ @use "uswds-core" as *; +@use "cisa_colors" as *; .usa-form .usa-button { margin-top: units(3); @@ -28,7 +29,7 @@ .usa-form-readonly { // todo update - border-top: 2px black solid; + border-top: 2px #{$dhs-dark-gray-15} solid; .bold-usa-label label.usa-label{ font-weight: bold; diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index 39429d4eb..b9d1ca6f6 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -7,7 +7,7 @@
- {% include "includes/form_messages.html" %} + {% include "includes/form_errors.html" with form=form %} {% comment %} Repurposed from domain_request_form.html {% endcomment %} diff --git a/src/registrar/templates/includes/input_with_errors.html b/src/registrar/templates/includes/input_with_errors.html index 43e90dca6..9348713b3 100644 --- a/src/registrar/templates/includes/input_with_errors.html +++ b/src/registrar/templates/includes/input_with_errors.html @@ -28,7 +28,7 @@ error messages, if necessary. {% if not field.widget_type == "checkbox" %} {% if show_edit_button %} - {% include "includes/label_with_edit_button.html" %} + {% include "includes/label_with_edit_button.html" with bold_label=True %} {% else %} {% include "django/forms/label.html" %} {% endif %} diff --git a/src/registrar/templates/includes/label_with_edit_button.html b/src/registrar/templates/includes/label_with_edit_button.html index bbff26400..ab0a04ee4 100644 --- a/src/registrar/templates/includes/label_with_edit_button.html +++ b/src/registrar/templates/includes/label_with_edit_button.html @@ -1,6 +1,6 @@ {% load static field_helpers url_helpers %} -
+
{% include "django/forms/label.html" %}
diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 45c7c7860..49d172971 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -342,18 +342,25 @@ class ContactPermission(PermissionsLoginMixin): return False - given_user_pk = self.kwargs["pk"] + given_contact_pk = self.kwargs["pk"] # Grab the user in the DB to do a full object comparision, not just on ids current_user = self.request.user - # Check for the ids existence since we're dealing with requests - requested_user_exists = User.objects.filter(pk=given_user_pk).exists() - # Compare the PK that was passed in to the user currently logged in - if current_user.pk != given_user_pk and requested_user_exists: + if current_user.contact.pk != given_contact_pk: # Don't allow users to modify other users profiles return False + + # Check if the object at the id we're searching on actually exists + requested_user_exists = User.objects.filter(pk=current_user.pk).exists() + requested_contact_exists = Contact.objects.filter( + user=current_user.pk, + pk=given_contact_pk + ).exists() + + if not requested_user_exists or not requested_contact_exists: + return False # Check if the user has an associated contact associated_contacts = Contact.objects.filter(user=current_user) From 4592b0c9e67ea6ba90bdf5071f7bf6d5e839afef Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 13 May 2024 12:52:35 -0600 Subject: [PATCH 022/214] Redirect logic --- src/registrar/assets/js/get-gov.js | 17 +++-- src/registrar/forms/contact.py | 40 +++++------- src/registrar/models/contact.py | 19 +++--- .../models/utility/generic_helper.py | 20 ++++++ src/registrar/registrar_middleware.py | 4 +- .../templates/finish_contact_setup.html | 13 ++-- src/registrar/views/contact.py | 62 ++++++++++++++----- 7 files changed, 112 insertions(+), 63 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 99fd9b3a1..73afd8131 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -895,13 +895,7 @@ function hideDeletedForms() { // Lock the edit button while this operation occurs button.disabled = true - if (fieldName == "full_name"){ - let nameFields = ["first_name", "middle_name", "last_name"] - handleFullNameField(fieldName, nameFields); - }else { - showInputFieldHideReadonlyField(fieldName, button); - } - + showInputFieldHideReadonlyField(fieldName, button); button.classList.add('display-none'); // Unlock after it completes @@ -928,10 +922,15 @@ function hideDeletedForms() { let fieldIdParts = button.id.split("__") if (fieldIdParts && fieldIdParts.length > 0){ let fieldName = fieldIdParts[0] - + let errorMessage = document.querySelector(`#id_${fieldName}__error-message`); if (errorMessage) { - button.click() + if (fieldName == "full_name"){ + let nameFields = ["first_name", "middle_name", "last_name"] + handleFullNameField(fieldName, nameFields); + }else { + button.click() + } } } }); diff --git a/src/registrar/forms/contact.py b/src/registrar/forms/contact.py index 51c296375..83b49f548 100644 --- a/src/registrar/forms/contact.py +++ b/src/registrar/forms/contact.py @@ -1,6 +1,5 @@ from django import forms from phonenumber_field.formfields import PhoneNumberField # type: ignore -from django.core.validators import MaxLengthValidator class ContactForm(forms.Form): @@ -10,29 +9,25 @@ class ContactForm(forms.Form): cleaned_data = super().clean() # Remove the full name property if "full_name" in cleaned_data: - del cleaned_data["full_name"] + full_name: str = cleaned_data["full_name"] + if full_name: + name_fields = full_name.split(" ") + + + cleaned_data["first_name"] = name_fields[0] + if len(name_fields) == 2: + cleaned_data["last_name"] = " ".join(name_fields[1:]) + elif len(name_fields) > 2: + cleaned_data["middle_name"] = name_fields[1] + cleaned_data["last_name"] = " ".join(name_fields[2:]) + else: + cleaned_data["middle_name"] = None + cleaned_data["last_name"] = None + + # Delete the full name element as we don't need it anymore + del cleaned_data["full_name"] return cleaned_data - def to_database(self, obj): - """ - Adds this form's cleaned data to `obj` and saves `obj`. - - Does nothing if form is not valid. - """ - if not self.is_valid(): - return - for name, value in self.cleaned_data.items(): - setattr(obj, name, value) - obj.save() - - @classmethod - def from_database(cls, obj): - """Returns a dict of form field values gotten from `obj`.""" - if obj is None: - return {} - return {name: getattr(obj, name) for name in cls.declared_fields.keys()} # type: ignore - - full_name = forms.CharField( label="Full name", error_messages={"required": "Enter your full name"}, @@ -57,7 +52,6 @@ class ContactForm(forms.Form): ) email = forms.EmailField( label="Organization email", - max_length=None, required=False, ) phone = PhoneNumberField( diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index 69d28df52..119b78fa6 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -92,6 +92,13 @@ class Contact(TimeStampedModel): names = [n for n in [self.first_name, self.middle_name, self.last_name] if n] return " ".join(names) if names else "Unknown" + @property + def full_name(self): + """ + Returns the full name (first_name, middle_name, last_name) of this contact. + """ + return self.get_formatted_name() + def has_contact_info(self): return bool(self.title or self.email or self.phone) @@ -126,15 +133,3 @@ class Contact(TimeStampedModel): return str(self.pk) else: return "" - - @property - def full_name(self, separator=" "): - """ - Returns the full name (first_name, middle_name, last_name) of this contact. - Seperator (which defaults to a blank space) determines the seperator for each of those fields. - For instance, with seperator=", " - this function would return this: - "First, Middle, Last" - """ - # Filter out empty strings to avoid extra spaces or separators - parts = [self.first_name or "", self.middle_name or "", self.last_name or ""] - return separator.join(parts) \ No newline at end of file diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index 209c0303f..8f504ad9e 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -261,3 +261,23 @@ class CreateOrUpdateOrganizationTypeHelper: return False else: return True + + +def to_database(form, obj): + """ + Adds the form's cleaned data to `obj` and saves `obj`. + + Does nothing if form is not valid. + """ + if not form.is_valid(): + return None + for name, value in form.cleaned_data.items(): + setattr(obj, name, value) + obj.save() + + +def from_database(form_class, obj): + """Returns a dict of form field values gotten from `obj`.""" + if obj is None: + return {} + return {name: getattr(obj, name) for name in form_class.declared_fields.keys()} # type: ignore \ No newline at end of file diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 783951279..2c420de96 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -23,7 +23,8 @@ class CheckUserProfileMiddleware: # Check if setup is not finished finished_setup = hasattr(request.user, "finished_setup") and request.user.finished_setup if request.user.is_authenticated and not finished_setup: - setup_page = reverse("finish-contact-profile-setup", kwargs={'pk': request.user.contact.pk}) + # redirect_to_domain_request = request.GET.get('domain_request', "") != "" + setup_page = reverse("finish-contact-profile-setup", kwargs={"pk": request.user.contact.pk}) logout_page = reverse("logout") excluded_pages = [ setup_page, @@ -32,6 +33,7 @@ class CheckUserProfileMiddleware: # Don't redirect on excluded pages (such as the setup page itself) if not any(request.path.startswith(page) for page in excluded_pages): + # Check if 'request' query parameter is not 'True' # Redirect to the setup page return HttpResponseRedirect(setup_page) diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index b9d1ca6f6..add20a5a9 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -24,7 +24,6 @@

Finish setting up your profile

- {% public_site_url 'help/account-management/#get-help-with-login.gov' %} We require that you maintain accurate contact information. The details you provide will only be used to support the administration of .gov and won’t be made public. @@ -79,9 +78,15 @@

- + {% if confirm_changes %} + + {% else %} + + {% endif %}
{% block form_fields %}{% endblock %}
diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index f70f59a26..745101ade 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -1,9 +1,12 @@ +from django.http import HttpResponseRedirect from django.urls import reverse from registrar.forms.contact import ContactForm from registrar.models.contact import Contact +from registrar.templatetags.url_helpers import public_site_url from registrar.views.utility.permission_views import ContactPermissionView from django.views.generic.edit import FormMixin - +from registrar.models.utility.generic_helper import to_database, from_database +from django.utils.safestring import mark_safe # TODO we can and probably should generalize this at this rate. class BaseContactView(ContactPermissionView): @@ -30,14 +33,15 @@ class BaseContactView(ContactPermissionView): self.object = cached_contact else: self.object = self.get_object() + self._update_session_with_contact() def _update_session_with_contact(self): """ Set contact pk in the session cache """ - domain_pk = "contact:" + str(self.kwargs.get("pk")) - self.session[domain_pk] = self.object + contact_pk = "contact:" + str(self.kwargs.get("pk")) + self.session[contact_pk] = self.object class ContactFormBaseView(BaseContactView, FormMixin): @@ -50,13 +54,9 @@ class ContactFormBaseView(BaseContactView, FormMixin): # Set the current contact object in cache self._set_contact(request) - # Get the current form and validate it form = self.get_form() - return self.check_form(form) - - # TODO rename? - def check_form(self, form): + # Get the current form and validate it return self.form_valid(form) if form.is_valid() else self.form_invalid(form) def form_invalid(self, form): @@ -74,31 +74,65 @@ class ContactProfileSetupView(ContactFormBaseView): form_class = ContactForm model = Contact + def post(self, request, *args, **kwargs): + """Form submission posts to this view. + + This post method harmonizes using BaseContactView and FormMixin + """ + # Set the current contact object in cache + self._set_contact(request) + + form = self.get_form() + + # Get the current form and validate it + if form.is_valid(): + if "redirect_to_home" not in self.session or not self.session["redirect_to_home"]: + self.session["redirect_to_home"] = "contact_setup_submit_button" in request.POST + return self.form_valid(form) + else: + return self.form_invalid(form) + def get_success_url(self): """Redirect to the nameservers page for the domain.""" + # TODO - some logic should exist that navigates them to the domain request page if # they clicked it on get.gov # Add a notification that the update was successful - return reverse("home") + if "redirect_to_home" in self.session and self.session["redirect_to_home"]: + return reverse("home") + else: + # Redirect to the same page with a query parameter to confirm changes + self.session["redirect_to_home"] = True + return reverse("finish-contact-profile-setup", kwargs={"pk": self.object.pk}) def form_valid(self, form): self.request.user.finished_setup = True self.request.user.save() - form.to_database(self.object) + to_database(form=form, obj=self.object) self._update_session_with_contact() return super().form_valid(form) def get_initial(self): """The initial value for the form (which is a formset here).""" - db_object = self.form_class.from_database(self.object) + db_object = from_database(form_class=self.form_class, obj=self.object) return db_object def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["email_sublabel_text"] = ( + context["email_sublabel_text"] = self._email_sublabel_text() + + if "redirect_to_home" in self.session and self.session["redirect_to_home"]: + context['confirm_changes'] = True + + return context + + def _email_sublabel_text(self): + """Returns the lengthy sublabel for the email field""" + help_url = public_site_url('help/account-management/#get-help-with-login.gov') + return mark_safe( "We recommend using your work email for your .gov account. " "If the wrong email is displayed below, you’ll need to update your Login.gov account " - "and log back in. Get help with your Login.gov account.") - return context + f'and log back in. Get help with your Login.gov account.' + ) From f1f8a6275331deefee5d7831132ad271ededdf66 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 13 May 2024 14:34:09 -0600 Subject: [PATCH 023/214] Finish redirect logic Still needs some cleanup --- src/registrar/registrar_middleware.py | 11 ++- .../templates/finish_contact_setup.html | 10 ++- src/registrar/views/contact.py | 87 +++++++++++++++---- 3 files changed, 83 insertions(+), 25 deletions(-) diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 2c420de96..f357f1050 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -24,7 +24,10 @@ class CheckUserProfileMiddleware: finished_setup = hasattr(request.user, "finished_setup") and request.user.finished_setup if request.user.is_authenticated and not finished_setup: # redirect_to_domain_request = request.GET.get('domain_request', "") != "" - setup_page = reverse("finish-contact-profile-setup", kwargs={"pk": request.user.contact.pk}) + setup_page = reverse( + "finish-contact-profile-setup", + kwargs={"pk": request.user.contact.pk} + ) logout_page = reverse("logout") excluded_pages = [ setup_page, @@ -33,7 +36,11 @@ class CheckUserProfileMiddleware: # Don't redirect on excluded pages (such as the setup page itself) if not any(request.path.startswith(page) for page in excluded_pages): - # Check if 'request' query parameter is not 'True' + # Preserve the original query parameters + query_params = request.GET.urlencode() + if query_params: + setup_page += f"?{query_params}" + # Redirect to the setup page return HttpResponseRedirect(setup_page) diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index add20a5a9..58eee0b17 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -78,14 +78,16 @@
+ {% if confirm_changes %} - - {% else %} + {% else %} + + {% endif %}
{% block form_fields %}{% endblock %} diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index 745101ade..2c98730a7 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -1,3 +1,5 @@ +from enum import Enum +from urllib.parse import urlencode from django.http import HttpResponseRedirect from django.urls import reverse from registrar.forms.contact import ContactForm @@ -8,12 +10,16 @@ from django.views.generic.edit import FormMixin from registrar.models.utility.generic_helper import to_database, from_database from django.utils.safestring import mark_safe +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect + # TODO we can and probably should generalize this at this rate. class BaseContactView(ContactPermissionView): def get(self, request, *args, **kwargs): self._set_contact(request) context = self.get_context_data(object=self.object) + return self.render_to_response(context) # TODO - this deserves a small refactor @@ -74,11 +80,56 @@ class ContactProfileSetupView(ContactFormBaseView): form_class = ContactForm model = Contact + redirect_type = None + class RedirectType: + HOME = "home" + BACK_TO_SELF = "back_to_self" + DOMAIN_REQUEST = "domain_request" + + @method_decorator(csrf_protect) + def dispatch(self, request, *args, **kwargs): + # Default redirect type + default_redirect = self.RedirectType.BACK_TO_SELF + + # Update redirect type based on the query parameter if present + redirect_type = request.GET.get("redirect", default_redirect) + + # Store the redirect type in the session + self.redirect_type = redirect_type + + return super().dispatch(request, *args, **kwargs) + + def get_redirect_url(self): + match self.redirect_type: + case self.RedirectType.HOME: + return reverse("home") + case self.RedirectType.BACK_TO_SELF: + return reverse("finish-contact-profile-setup", kwargs={"pk": self.object.pk}) + case self.RedirectType.DOMAIN_REQUEST: + # TODO + return reverse("home") + case _: + return reverse("home") + + def get_success_url(self): + """Redirect to the nameservers page for the domain.""" + redirect_url = self.get_redirect_url() + return redirect_url + def post(self, request, *args, **kwargs): """Form submission posts to this view. This post method harmonizes using BaseContactView and FormMixin """ + # Default redirect type + default_redirect = self.RedirectType.BACK_TO_SELF + + # Update redirect type based on the query parameter if present + redirect_type = request.GET.get("redirect", default_redirect) + + # Store the redirect type in the session + self.redirect_type = redirect_type + # Set the current contact object in cache self._set_contact(request) @@ -86,28 +137,25 @@ class ContactProfileSetupView(ContactFormBaseView): # Get the current form and validate it if form.is_valid(): - if "redirect_to_home" not in self.session or not self.session["redirect_to_home"]: - self.session["redirect_to_home"] = "contact_setup_submit_button" in request.POST + if 'contact_setup_save_button' in request.POST: + # Logic for when the 'Save' button is clicked + self.redirect_type = self.RedirectType.BACK_TO_SELF + self.session["should_redirect_to_home"] = "redirect_to_home" in request.POST + elif 'contact_setup_submit_button' in request.POST: + # Logic for when the 'Save and continue' button is clicked + if self.redirect_type != self.RedirectType.DOMAIN_REQUEST: + self.redirect_type = self.RedirectType.HOME + else: + self.redirect_type = self.RedirectType.DOMAIN_REQUEST return self.form_valid(form) else: return self.form_invalid(form) - def get_success_url(self): - """Redirect to the nameservers page for the domain.""" - - # TODO - some logic should exist that navigates them to the domain request page if - # they clicked it on get.gov - # Add a notification that the update was successful - if "redirect_to_home" in self.session and self.session["redirect_to_home"]: - return reverse("home") - else: - # Redirect to the same page with a query parameter to confirm changes - self.session["redirect_to_home"] = True - return reverse("finish-contact-profile-setup", kwargs={"pk": self.object.pk}) - def form_valid(self, form): - self.request.user.finished_setup = True - self.request.user.save() + + if self.redirect_type == self.RedirectType.HOME: + self.request.user.finished_setup = True + self.request.user.save() to_database(form=form, obj=self.object) self._update_session_with_contact() @@ -120,11 +168,12 @@ class ContactProfileSetupView(ContactFormBaseView): return db_object def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) context["email_sublabel_text"] = self._email_sublabel_text() - if "redirect_to_home" in self.session and self.session["redirect_to_home"]: - context['confirm_changes'] = True + if "should_redirect_to_home" in self.session: + context["confirm_changes"] = True return context From aaa9047478f369376bbdf92b3e665a2789701c9e Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 14 May 2024 08:07:44 -0600 Subject: [PATCH 024/214] Add summary box to page --- src/registrar/assets/sass/_theme/_forms.scss | 3 ++ .../templates/domain_request_intro.html | 28 ++++++++++++++++++- .../templates/finish_contact_setup.html | 2 +- src/registrar/views/contact.py | 19 ++++--------- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index a4194273d..d291cf333 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -15,6 +15,9 @@ .usa-form--extra-large { max-width: none; + .usa-summary-box { + max-width: 600px; + } } .usa-form--text-width { diff --git a/src/registrar/templates/domain_request_intro.html b/src/registrar/templates/domain_request_intro.html index 72abe6a27..28ee3a2ef 100644 --- a/src/registrar/templates/domain_request_intro.html +++ b/src/registrar/templates/domain_request_intro.html @@ -13,8 +13,34 @@

We’ll use the information you provide to verify your organization’s eligibility for a .gov domain. We’ll also verify that the domain you request meets our guidelines.

Time to complete the form

If you have all the information you need, - completing your domain request might take around 15 minutes.

+ completing your domain request might take around 15 minutes.

+

How we’ll reach you

+

+ While reviewing your domain request, we may need to reach out with questions. + We’ll also email you when we complete our review. + If the contact information below is not correct, visit your profile to make updates. +

+ +
+
+

+ Your contact information +

+
+
    +
  • Full name: {{ user.contact.full_name }}
  • +
  • Organization email: {{ user.contact.email }}
  • +
  • Title or role in your organization: {{ user.contact.title }}
  • +
  • Phone: {{ user.contact.phone }}
  • +
+
+
+
{% block form_buttons %}
diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index 58eee0b17..4c9a6595b 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -87,7 +87,7 @@ - + {% endif %}
{% block form_fields %}{% endblock %} diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index 2c98730a7..b7396f734 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -94,7 +94,6 @@ class ContactProfileSetupView(ContactFormBaseView): # Update redirect type based on the query parameter if present redirect_type = request.GET.get("redirect", default_redirect) - # Store the redirect type in the session self.redirect_type = redirect_type return super().dispatch(request, *args, **kwargs) @@ -106,8 +105,7 @@ class ContactProfileSetupView(ContactFormBaseView): case self.RedirectType.BACK_TO_SELF: return reverse("finish-contact-profile-setup", kwargs={"pk": self.object.pk}) case self.RedirectType.DOMAIN_REQUEST: - # TODO - return reverse("home") + return reverse("domain-request:") case _: return reverse("home") @@ -116,19 +114,12 @@ class ContactProfileSetupView(ContactFormBaseView): redirect_url = self.get_redirect_url() return redirect_url + # TODO - delete session information def post(self, request, *args, **kwargs): """Form submission posts to this view. This post method harmonizes using BaseContactView and FormMixin """ - # Default redirect type - default_redirect = self.RedirectType.BACK_TO_SELF - - # Update redirect type based on the query parameter if present - redirect_type = request.GET.get("redirect", default_redirect) - - # Store the redirect type in the session - self.redirect_type = redirect_type # Set the current contact object in cache self._set_contact(request) @@ -140,7 +131,7 @@ class ContactProfileSetupView(ContactFormBaseView): if 'contact_setup_save_button' in request.POST: # Logic for when the 'Save' button is clicked self.redirect_type = self.RedirectType.BACK_TO_SELF - self.session["should_redirect_to_home"] = "redirect_to_home" in request.POST + self.session["should_redirect"] = "redirect_to_confirmation_page" in request.POST elif 'contact_setup_submit_button' in request.POST: # Logic for when the 'Save and continue' button is clicked if self.redirect_type != self.RedirectType.DOMAIN_REQUEST: @@ -153,7 +144,7 @@ class ContactProfileSetupView(ContactFormBaseView): def form_valid(self, form): - if self.redirect_type == self.RedirectType.HOME: + if self.redirect_type != self.RedirectType.BACK_TO_SELF: self.request.user.finished_setup = True self.request.user.save() @@ -172,7 +163,7 @@ class ContactProfileSetupView(ContactFormBaseView): context = super().get_context_data(**kwargs) context["email_sublabel_text"] = self._email_sublabel_text() - if "should_redirect_to_home" in self.session: + if "should_redirect" in self.session: context["confirm_changes"] = True return context From b741540bdfd18ec495b33a6975daf87a3eb99775 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 14 May 2024 10:03:10 -0600 Subject: [PATCH 025/214] Polishing touches --- src/registrar/assets/js/get-gov.js | 53 ++++++++++++------- src/registrar/assets/sass/_theme/_forms.scss | 11 ++++ src/registrar/forms/contact.py | 22 ++------ .../templates/finish_contact_setup.html | 10 ++-- .../templates/includes/readonly_input.html | 4 +- .../templates/includes/required_fields.html | 2 +- 6 files changed, 59 insertions(+), 43 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 73afd8131..98a08fc2c 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -842,6 +842,14 @@ function hideDeletedForms() { */ (function finishUserSetupListener() { + function getInputFieldId(fieldName){ + return `#id_${fieldName}` + } + + function getReadonlyFieldId(fieldName){ + return `#${fieldName}__edit-button-readonly` + } + // Shows the hidden input field and hides the readonly one function showInputFieldHideReadonlyField(fieldName, button) { let inputId = getInputFieldId(fieldName) @@ -854,29 +862,19 @@ function hideDeletedForms() { inputField.classList.toggle('display-none'); // Toggle the bold style on the grid row - let formGroup = button.closest(".usa-form-group") - if (formGroup){ - gridRow = button.querySelector(".grid-row") - if (gridRow){ - gridRow.toggle("bold-usa-label") - } + let gridRow = button.closest(".grid-col-2").closest(".grid-row") + if (gridRow){ + gridRow.classList.toggle("bold-usa-label") } } - function getInputFieldId(fieldName){ - return `#id_${fieldName}` - } - - function getReadonlyFieldId(fieldName){ - return `#${fieldName}__edit-button-readonly` - } - function handleFullNameField(fieldName, nameFields) { // Remove the display-none class from the nearest parent div let fieldId = getInputFieldId(fieldName) let inputField = document.querySelector(fieldId); if (inputField) { + // Show each name field nameFields.forEach(function(fieldName) { let nameId = getInputFieldId(fieldName) let nameField = document.querySelector(nameId); @@ -887,7 +885,14 @@ function hideDeletedForms() { } } }); + + // Remove the "full_name" field + inputFieldParentDiv = inputField.closest("div"); + if (inputFieldParentDiv) { + inputFieldParentDiv.remove(); + } } + } function handleEditButtonClick(fieldName, button){ @@ -895,8 +900,14 @@ function hideDeletedForms() { // Lock the edit button while this operation occurs button.disabled = true - showInputFieldHideReadonlyField(fieldName, button); - button.classList.add('display-none'); + if (fieldName == "full_name"){ + let nameFields = ["first_name", "middle_name", "last_name"] + handleFullNameField(fieldName, nameFields); + }else { + showInputFieldHideReadonlyField(fieldName, button); + } + + button.classList.add("display-none"); // Unlock after it completes button.disabled = false @@ -925,10 +936,16 @@ function hideDeletedForms() { let errorMessage = document.querySelector(`#id_${fieldName}__error-message`); if (errorMessage) { + let nameFields = ["first_name", "middle_name", "last_name"] + // If either the full_name field errors out, + // or if any of its associated fields do - show all name related fields. + // Otherwise, just show the problematic field. if (fieldName == "full_name"){ - let nameFields = ["first_name", "middle_name", "last_name"] handleFullNameField(fieldName, nameFields); - }else { + }else if (nameFields.includes(fieldName)){ + handleFullNameField("full_name", nameFields); + } + else { button.click() } } diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index d291cf333..b198bbc18 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -38,6 +38,10 @@ font-weight: bold; } + &.bold-usa-label label.usa-label{ + font-weight: bold; + } + } .usa-form-readonly:first-of-type { @@ -62,6 +66,13 @@ legend.float-left-tablet + button.float-right-tablet { } } +@media (min-width: 35em) { + .usa-form--largest { + max-width: 35rem; + } +} + + // Custom style for disabled inputs @media (prefers-color-scheme: light) { .usa-input:disabled, .usa-select:disabled, .usa-textarea:disabled { diff --git a/src/registrar/forms/contact.py b/src/registrar/forms/contact.py index 83b49f548..fa0bf1fce 100644 --- a/src/registrar/forms/contact.py +++ b/src/registrar/forms/contact.py @@ -9,23 +9,10 @@ class ContactForm(forms.Form): cleaned_data = super().clean() # Remove the full name property if "full_name" in cleaned_data: - full_name: str = cleaned_data["full_name"] - if full_name: - name_fields = full_name.split(" ") - - - cleaned_data["first_name"] = name_fields[0] - if len(name_fields) == 2: - cleaned_data["last_name"] = " ".join(name_fields[1:]) - elif len(name_fields) > 2: - cleaned_data["middle_name"] = name_fields[1] - cleaned_data["last_name"] = " ".join(name_fields[2:]) - else: - cleaned_data["middle_name"] = None - cleaned_data["last_name"] = None - - # Delete the full name element as we don't need it anymore - del cleaned_data["full_name"] + # Delete the full name element as its purely decorative. + # We include it as a normal Charfield for all the advantages + # and utility that it brings, but we're playing pretend. + del cleaned_data["full_name"] return cleaned_data full_name = forms.CharField( @@ -53,6 +40,7 @@ class ContactForm(forms.Form): email = forms.EmailField( label="Organization email", required=False, + max_length=None, ) phone = PhoneNumberField( label="Phone", diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index 4c9a6595b..e856927bb 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -34,9 +34,9 @@ Review the details below and update any required information. Note that editing this information won’t affect your Login.gov account information.

- {# TODO: maybe remove this? #} -

Required information is marked with an asterisk (*).

- + {# We use a var called 'remove_margin_top' rather than 'add_margin_top' because most fields use this #} + {% include "includes/required_fields.html" with remove_margin_top=True %} + {% csrf_token %}
@@ -61,8 +61,8 @@ {% input_with_errors form.last_name %} {% endwith %} - {# TODO: I shouldnt need to do add_class here #} - {% with show_readonly=True add_class="display-none" group_classes="usa-form-readonly padding-top-2" sublabel_text=email_sublabel_text %} + {# This field doesn't have the readonly button but it has common design elements from it #} + {% with show_readonly=True add_class="display-none" group_classes="usa-form-readonly padding-top-2 bold-usa-label" sublabel_text=email_sublabel_text %} {% input_with_errors form.email %} {% endwith %} diff --git a/src/registrar/templates/includes/readonly_input.html b/src/registrar/templates/includes/readonly_input.html index 820d4b66c..59a55090c 100644 --- a/src/registrar/templates/includes/readonly_input.html +++ b/src/registrar/templates/includes/readonly_input.html @@ -1,6 +1,6 @@ {% load static field_helpers url_helpers %} -
+
-
+
{{ field.value }}
diff --git a/src/registrar/templates/includes/required_fields.html b/src/registrar/templates/includes/required_fields.html index 0087b048a..be0395979 100644 --- a/src/registrar/templates/includes/required_fields.html +++ b/src/registrar/templates/includes/required_fields.html @@ -1,3 +1,3 @@ -

+

Required fields are marked with an asterisk (*).

From 1fd58d6d843b916dd7551b6c636b887867c10342 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 14 May 2024 11:49:55 -0600 Subject: [PATCH 026/214] Redirect logic --- .../templates/finish_contact_setup.html | 1 - src/registrar/views/contact.py | 129 ++++++++++++++---- 2 files changed, 100 insertions(+), 30 deletions(-) diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index e856927bb..68e860267 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -87,7 +87,6 @@ - {% endif %}
{% block form_fields %}{% endblock %} diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index b7396f734..0fbd92435 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -1,7 +1,6 @@ from enum import Enum -from urllib.parse import urlencode -from django.http import HttpResponseRedirect -from django.urls import reverse +from urllib.parse import urlencode, urlunparse, urlparse, quote +from django.urls import NoReverseMatch, reverse from registrar.forms.contact import ContactForm from registrar.models.contact import Contact from registrar.templatetags.url_helpers import public_site_url @@ -13,19 +12,23 @@ from django.utils.safestring import mark_safe from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_protect -# TODO we can and probably should generalize this at this rate. +import logging + +logger = logging.getLogger(__name__) + + class BaseContactView(ContactPermissionView): def get(self, request, *args, **kwargs): + """Sets the current contact in cache, defines the current object as self.object + then returns render_to_response""" self._set_contact(request) context = self.get_context_data(object=self.object) - return self.render_to_response(context) - # TODO - this deserves a small refactor def _set_contact(self, request): """ - get domain from session cache or from db and set + get contact from session cache or from db and set to self.object set session to self for downstream functions to update session cache @@ -81,34 +84,99 @@ class ContactProfileSetupView(ContactFormBaseView): model = Contact redirect_type = None + + # TODO - make this an enum class RedirectType: + """ + Contains constants for each type of redirection. + Not an enum as we just need to track string values, + but we don't care about enforcing it. + + - HOME: We want to redirect to reverse("home") + - BACK_TO_SELF: We want to redirect back to reverse("finish-contact-profile-setup") + - TO_SPECIFIC_PAGE: We want to redirect to the page specified in the queryparam "redirect" + - COMPLETE_SETUP: Indicates that we want to navigate BACK_TO_SELF, but the subsequent + redirect after the next POST should be either HOME or TO_SPECIFIC_PAGE + """ HOME = "home" BACK_TO_SELF = "back_to_self" - DOMAIN_REQUEST = "domain_request" + COMPLETE_SETUP = "complete_setup" + TO_SPECIFIC_PAGE = "domain_request" + # TODO - refactor @method_decorator(csrf_protect) def dispatch(self, request, *args, **kwargs): + # Default redirect type default_redirect = self.RedirectType.BACK_TO_SELF # Update redirect type based on the query parameter if present - redirect_type = request.GET.get("redirect", default_redirect) + redirect_type = request.GET.get("redirect", None) - self.redirect_type = redirect_type + is_default = False + # We set this here rather than in .get so we don't override + # existing data if no queryparam is present. + if redirect_type is None: + is_default = True + redirect_type = default_redirect + + # Set the default if nothing exists already + if self.redirect_type is None: + self.redirect_type = redirect_type + + if not is_default: + default_redirects = [ + self.RedirectType.HOME, + self.RedirectType.COMPLETE_SETUP, + self.RedirectType.BACK_TO_SELF, + self.RedirectType.TO_SPECIFIC_PAGE + ] + if redirect_type not in default_redirects: + self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE + request.session["profile_setup_redirect_viewname"] = redirect_type + else: + self.redirect_type = redirect_type return super().dispatch(request, *args, **kwargs) def get_redirect_url(self): + base_url = "" + query_params = {} match self.redirect_type: case self.RedirectType.HOME: - return reverse("home") - case self.RedirectType.BACK_TO_SELF: - return reverse("finish-contact-profile-setup", kwargs={"pk": self.object.pk}) - case self.RedirectType.DOMAIN_REQUEST: - return reverse("domain-request:") + base_url = reverse("home") + case self.RedirectType.BACK_TO_SELF | self.RedirectType.COMPLETE_SETUP: + base_url = reverse("finish-contact-profile-setup", kwargs={"pk": self.object.pk}) + case self.RedirectType.TO_SPECIFIC_PAGE: + + # We only allow this session value to use viewnames, + # because otherwise this allows anyone to enter any value in here. + # This restricts what can be redirected to. + try: + desired_view = self.session["profile_setup_redirect_viewname"] + base_url = reverse(desired_view) + except NoReverseMatch as err: + logger.error(err) + logger.error( + "ContactProfileSetupView -> get_redirect_url -> Could not find specified page." + ) + base_url = reverse("home") case _: - return reverse("home") - + base_url = reverse("home") + + # Quote cleans up the value so that it can be used in a url + query_params["redirect"] = quote(self.redirect_type) + + # Parse the base URL + url_parts = list(urlparse(base_url)) + + # Update the query part of the URL + url_parts[4] = urlencode(query_params) + + # Construct the full URL with query parameters + full_url = urlunparse(url_parts) + return full_url + def get_success_url(self): """Redirect to the nameservers page for the domain.""" redirect_url = self.get_redirect_url() @@ -128,23 +196,26 @@ class ContactProfileSetupView(ContactFormBaseView): # Get the current form and validate it if form.is_valid(): - if 'contact_setup_save_button' in request.POST: + if "contact_setup_save_button" in request.POST: # Logic for when the 'Save' button is clicked - self.redirect_type = self.RedirectType.BACK_TO_SELF - self.session["should_redirect"] = "redirect_to_confirmation_page" in request.POST - elif 'contact_setup_submit_button' in request.POST: - # Logic for when the 'Save and continue' button is clicked - if self.redirect_type != self.RedirectType.DOMAIN_REQUEST: - self.redirect_type = self.RedirectType.HOME + self.redirect_type = self.RedirectType.COMPLETE_SETUP + elif "contact_setup_submit_button" in request.POST: + if "profile_setup_redirect_viewname" in self.session: + self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE else: - self.redirect_type = self.RedirectType.DOMAIN_REQUEST + self.redirect_type = self.RedirectType.HOME + return self.form_valid(form) else: return self.form_invalid(form) def form_valid(self, form): - - if self.redirect_type != self.RedirectType.BACK_TO_SELF: + + completed_states = [ + self.RedirectType.TO_SPECIFIC_PAGE, + self.RedirectType.HOME + ] + if self.redirect_type in completed_states: self.request.user.finished_setup = True self.request.user.save() @@ -163,14 +234,14 @@ class ContactProfileSetupView(ContactFormBaseView): context = super().get_context_data(**kwargs) context["email_sublabel_text"] = self._email_sublabel_text() - if "should_redirect" in self.session: + if self.redirect_type == self.RedirectType.COMPLETE_SETUP: context["confirm_changes"] = True return context def _email_sublabel_text(self): """Returns the lengthy sublabel for the email field""" - help_url = public_site_url('help/account-management/#get-help-with-login.gov') + help_url = public_site_url("help/account-management/#get-help-with-login.gov") return mark_safe( "We recommend using your work email for your .gov account. " "If the wrong email is displayed below, you’ll need to update your Login.gov account " From c352869f6be066c3b3718227a047b073c958ae0e Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 14 May 2024 12:40:29 -0600 Subject: [PATCH 027/214] Redirect logic from home --- src/registrar/registrar_middleware.py | 30 ++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index f357f1050..a9b2f7a23 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -1,8 +1,8 @@ """ Contains middleware used in settings.py """ - -from django.urls import reverse +from urllib.parse import urlparse, urlunparse, parse_qs, urlencode +from django.urls import reverse, resolve from django.http import HttpResponseRedirect class CheckUserProfileMiddleware: @@ -20,6 +20,8 @@ class CheckUserProfileMiddleware: return response def process_view(self, request, view_func, view_args, view_kwargs): + + # Check if setup is not finished finished_setup = hasattr(request.user, "finished_setup") and request.user.finished_setup if request.user.is_authenticated and not finished_setup: @@ -33,13 +35,31 @@ class CheckUserProfileMiddleware: setup_page, logout_page, ] + custom_redirect = None + + # In some cases, we don't want to redirect to home. + # This handles that. + if request.path == "/request/": + # This can be generalized if need be, but for now lets keep this easy to read. + custom_redirect = "domain-request:" # Don't redirect on excluded pages (such as the setup page itself) if not any(request.path.startswith(page) for page in excluded_pages): - # Preserve the original query parameters - query_params = request.GET.urlencode() + # Preserve the original query parameters, and coerce them into a dict + query_params = parse_qs(request.META['QUERY_STRING']) + + if custom_redirect is not None: + # Set the redirect value to our redirect location + query_params["redirect"] = custom_redirect + if query_params: - setup_page += f"?{query_params}" + # Split the URL into parts + setup_page_parts = list(urlparse(setup_page)) + # Modify the query param bit + setup_page_parts[4] = urlencode(query_params) + # Reassemble the URL + setup_page = urlunparse(setup_page_parts) + # Redirect to the setup page return HttpResponseRedirect(setup_page) From 059a99aca1bd62812c6d549b9caab607c17e75fb Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 14 May 2024 13:26:48 -0600 Subject: [PATCH 028/214] Add waffle flags --- src/registrar/registrar_middleware.py | 10 ++++++++-- src/registrar/views/contact.py | 18 +++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index a9b2f7a23..427775f34 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -2,8 +2,9 @@ Contains middleware used in settings.py """ from urllib.parse import urlparse, urlunparse, parse_qs, urlencode -from django.urls import reverse, resolve +from django.urls import reverse from django.http import HttpResponseRedirect +from waffle.decorators import flag_is_active class CheckUserProfileMiddleware: """ @@ -20,12 +21,17 @@ class CheckUserProfileMiddleware: return response def process_view(self, request, view_func, view_args, view_kwargs): + + # Check that the user is "opted-in" to the profile feature flag + has_profile_feature_flag = flag_is_active(request, "profile_feature") + # If they aren't, skip this entirely + if not has_profile_feature_flag: + return None # Check if setup is not finished finished_setup = hasattr(request.user, "finished_setup") and request.user.finished_setup if request.user.is_authenticated and not finished_setup: - # redirect_to_domain_request = request.GET.get('domain_request', "") != "" setup_page = reverse( "finish-contact-profile-setup", kwargs={"pk": request.user.contact.pk} diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index 0fbd92435..f2f5e29a9 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -1,4 +1,4 @@ -from enum import Enum +from waffle.decorators import waffle_flag from urllib.parse import urlencode, urlunparse, urlparse, quote from django.urls import NoReverseMatch, reverse from registrar.forms.contact import ContactForm @@ -18,7 +18,8 @@ logger = logging.getLogger(__name__) class BaseContactView(ContactPermissionView): - + """Provides a base view for the contact object. On get, the contact + is saved in the session and on self.object.""" def get(self, request, *args, **kwargs): """Sets the current contact in cache, defines the current object as self.object then returns render_to_response""" @@ -54,7 +55,7 @@ class BaseContactView(ContactPermissionView): class ContactFormBaseView(BaseContactView, FormMixin): - + """Adds a FormMixin to BaseContactView, and handles post""" def post(self, request, *args, **kwargs): """Form submission posts to this view. @@ -104,6 +105,7 @@ class ContactProfileSetupView(ContactFormBaseView): TO_SPECIFIC_PAGE = "domain_request" # TODO - refactor + @waffle_flag('profile_feature') @method_decorator(csrf_protect) def dispatch(self, request, *args, **kwargs): @@ -140,6 +142,16 @@ class ContactProfileSetupView(ContactFormBaseView): return super().dispatch(request, *args, **kwargs) def get_redirect_url(self): + """ + Returns a URL string based on the current value of self.redirect_type. + + Depending on self.redirect_type, constructs a base URL and appends a + 'redirect' query parameter. Handles different redirection types such as + HOME, BACK_TO_SELF, COMPLETE_SETUP, and TO_SPECIFIC_PAGE. + + Returns: + str: The full URL with the appropriate query parameters. + """ base_url = "" query_params = {} match self.redirect_type: From 54c532f3b241df5f64b7f5857ee8314ef3e1fdf2 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 14 May 2024 13:33:07 -0600 Subject: [PATCH 029/214] Add comment on dispatch --- src/registrar/views/contact.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index f2f5e29a9..d8ea5a041 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -108,7 +108,16 @@ class ContactProfileSetupView(ContactFormBaseView): @waffle_flag('profile_feature') @method_decorator(csrf_protect) def dispatch(self, request, *args, **kwargs): - + """ + Handles dispatching of the view, applying CSRF protection and checking the 'profile_feature' flag. + + This method sets the redirect type based on the 'redirect' query parameter, + defaulting to BACK_TO_SELF if not provided. + It updates the session with the redirect view name if the redirect type is TO_SPECIFIC_PAGE. + + Returns: + HttpResponse: The response generated by the parent class's dispatch method. + """ # Default redirect type default_redirect = self.RedirectType.BACK_TO_SELF From f55d0a655a7e4093d8ffbe27b87fa096fa6d2693 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 14 May 2024 13:49:43 -0600 Subject: [PATCH 030/214] Cleanup --- src/djangooidc/backends.py | 5 ++- src/registrar/forms/contact.py | 1 - src/registrar/forms/domain.py | 1 + src/registrar/forms/domain_request_wizard.py | 2 +- src/registrar/models/contact.py | 1 + .../models/utility/generic_helper.py | 2 +- src/registrar/registrar_middleware.py | 17 ++++---- src/registrar/views/contact.py | 43 +++++++++---------- src/registrar/views/domain_request.py | 1 - src/registrar/views/utility/mixins.py | 8 +--- 10 files changed, 38 insertions(+), 43 deletions(-) diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py index 96b7a902a..8e4eb023d 100644 --- a/src/djangooidc/backends.py +++ b/src/djangooidc/backends.py @@ -50,7 +50,7 @@ class OpenIdConnectBackend(ModelBackend): user, created = UserModel.objects.get_or_create(**args) - if created: + if created and request is not None: request.session["is_new_user"] = True if not created: @@ -63,7 +63,8 @@ class OpenIdConnectBackend(ModelBackend): try: user = UserModel.objects.get_by_natural_key(username) except UserModel.DoesNotExist: - request.session["is_new_user"] = True + if request is not None: + request.session["is_new_user"] = True return None # run this callback for a each login user.on_each_login() diff --git a/src/registrar/forms/contact.py b/src/registrar/forms/contact.py index fa0bf1fce..9a752bab4 100644 --- a/src/registrar/forms/contact.py +++ b/src/registrar/forms/contact.py @@ -46,4 +46,3 @@ class ContactForm(forms.Form): label="Phone", error_messages={"invalid": "Enter a valid 10-digit phone number.", "required": "Enter your phone number."}, ) - diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 9dfd9773a..db247ad21 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -202,6 +202,7 @@ NameserverFormset = formset_factory( validate_max=True, ) + # TODO - refactor, wait until daves PR class ContactForm(forms.ModelForm): """Form for updating contacts.""" diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 9d16a30de..dd29df523 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -150,7 +150,7 @@ class OrganizationContactForm(RegistrarForm): """Require something to be selected when this is a federal agency.""" federal_agency = self.cleaned_data.get("federal_agency", None) # need the domain request object to know if this is federal - if self.domain_request is None: + if hasattr(self, "domain_request") and self.domain_request is None: # hmm, no saved domain request object?, default require the agency if not federal_agency: # no answer was selected diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index 119b78fa6..6e196783b 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -3,6 +3,7 @@ from django.db import models from .utility.time_stamped_model import TimeStampedModel from phonenumber_field.modelfields import PhoneNumberField # type: ignore + class Contact(TimeStampedModel): """Contact information follows a similar pattern for each contact.""" diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index 8f504ad9e..6ee598c13 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -280,4 +280,4 @@ def from_database(form_class, obj): """Returns a dict of form field values gotten from `obj`.""" if obj is None: return {} - return {name: getattr(obj, name) for name in form_class.declared_fields.keys()} # type: ignore \ No newline at end of file + return {name: getattr(obj, name) for name in form_class.declared_fields.keys()} # type: ignore diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 427775f34..8dca06019 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -1,17 +1,20 @@ """ Contains middleware used in settings.py """ + from urllib.parse import urlparse, urlunparse, parse_qs, urlencode from django.urls import reverse from django.http import HttpResponseRedirect from waffle.decorators import flag_is_active + class CheckUserProfileMiddleware: """ - Checks if the current user has finished_setup = False. + Checks if the current user has finished_setup = False. If they do, redirect them to the setup page regardless of where they are in the application. """ + def __init__(self, get_response): self.get_response = get_response @@ -21,7 +24,7 @@ class CheckUserProfileMiddleware: return response def process_view(self, request, view_func, view_args, view_kwargs): - + # Check that the user is "opted-in" to the profile feature flag has_profile_feature_flag = flag_is_active(request, "profile_feature") @@ -32,10 +35,7 @@ class CheckUserProfileMiddleware: # Check if setup is not finished finished_setup = hasattr(request.user, "finished_setup") and request.user.finished_setup if request.user.is_authenticated and not finished_setup: - setup_page = reverse( - "finish-contact-profile-setup", - kwargs={"pk": request.user.contact.pk} - ) + setup_page = reverse("finish-contact-profile-setup", kwargs={"pk": request.user.contact.pk}) logout_page = reverse("logout") excluded_pages = [ setup_page, @@ -52,7 +52,7 @@ class CheckUserProfileMiddleware: # Don't redirect on excluded pages (such as the setup page itself) if not any(request.path.startswith(page) for page in excluded_pages): # Preserve the original query parameters, and coerce them into a dict - query_params = parse_qs(request.META['QUERY_STRING']) + query_params = parse_qs(request.META["QUERY_STRING"]) if custom_redirect is not None: # Set the redirect value to our redirect location @@ -65,7 +65,6 @@ class CheckUserProfileMiddleware: setup_page_parts[4] = urlencode(query_params) # Reassemble the URL setup_page = urlunparse(setup_page_parts) - # Redirect to the setup page return HttpResponseRedirect(setup_page) @@ -88,4 +87,4 @@ class NoCacheMiddleware: def __call__(self, request): response = self.get_response(request) response["Cache-Control"] = "no-cache" - return response \ No newline at end of file + return response diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index d8ea5a041..322630b07 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -20,6 +20,7 @@ logger = logging.getLogger(__name__) class BaseContactView(ContactPermissionView): """Provides a base view for the contact object. On get, the contact is saved in the session and on self.object.""" + def get(self, request, *args, **kwargs): """Sets the current contact in cache, defines the current object as self.object then returns render_to_response""" @@ -56,6 +57,7 @@ class BaseContactView(ContactPermissionView): class ContactFormBaseView(BaseContactView, FormMixin): """Adds a FormMixin to BaseContactView, and handles post""" + def post(self, request, *args, **kwargs): """Form submission posts to this view. @@ -78,8 +80,9 @@ class ContactFormBaseView(BaseContactView, FormMixin): class ContactProfileSetupView(ContactFormBaseView): - """This view forces the user into providing additional details that + """This view forces the user into providing additional details that we may have missed from Login.gov""" + template_name = "finish_contact_setup.html" form_class = ContactForm model = Contact @@ -99,22 +102,23 @@ class ContactProfileSetupView(ContactFormBaseView): - COMPLETE_SETUP: Indicates that we want to navigate BACK_TO_SELF, but the subsequent redirect after the next POST should be either HOME or TO_SPECIFIC_PAGE """ + HOME = "home" BACK_TO_SELF = "back_to_self" COMPLETE_SETUP = "complete_setup" TO_SPECIFIC_PAGE = "domain_request" # TODO - refactor - @waffle_flag('profile_feature') + @waffle_flag("profile_feature") @method_decorator(csrf_protect) def dispatch(self, request, *args, **kwargs): """ Handles dispatching of the view, applying CSRF protection and checking the 'profile_feature' flag. - - This method sets the redirect type based on the 'redirect' query parameter, + + This method sets the redirect type based on the 'redirect' query parameter, defaulting to BACK_TO_SELF if not provided. It updates the session with the redirect view name if the redirect type is TO_SPECIFIC_PAGE. - + Returns: HttpResponse: The response generated by the parent class's dispatch method. """ @@ -140,7 +144,7 @@ class ContactProfileSetupView(ContactFormBaseView): self.RedirectType.HOME, self.RedirectType.COMPLETE_SETUP, self.RedirectType.BACK_TO_SELF, - self.RedirectType.TO_SPECIFIC_PAGE + self.RedirectType.TO_SPECIFIC_PAGE, ] if redirect_type not in default_redirects: self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE @@ -154,8 +158,8 @@ class ContactProfileSetupView(ContactFormBaseView): """ Returns a URL string based on the current value of self.redirect_type. - Depending on self.redirect_type, constructs a base URL and appends a - 'redirect' query parameter. Handles different redirection types such as + Depending on self.redirect_type, constructs a base URL and appends a + 'redirect' query parameter. Handles different redirection types such as HOME, BACK_TO_SELF, COMPLETE_SETUP, and TO_SPECIFIC_PAGE. Returns: @@ -178,13 +182,11 @@ class ContactProfileSetupView(ContactFormBaseView): base_url = reverse(desired_view) except NoReverseMatch as err: logger.error(err) - logger.error( - "ContactProfileSetupView -> get_redirect_url -> Could not find specified page." - ) + logger.error("ContactProfileSetupView -> get_redirect_url -> Could not find specified page.") base_url = reverse("home") case _: base_url = reverse("home") - + # Quote cleans up the value so that it can be used in a url query_params["redirect"] = quote(self.redirect_type) @@ -231,27 +233,24 @@ class ContactProfileSetupView(ContactFormBaseView): return self.form_invalid(form) def form_valid(self, form): - - completed_states = [ - self.RedirectType.TO_SPECIFIC_PAGE, - self.RedirectType.HOME - ] + + completed_states = [self.RedirectType.TO_SPECIFIC_PAGE, self.RedirectType.HOME] if self.redirect_type in completed_states: self.request.user.finished_setup = True self.request.user.save() - + to_database(form=form, obj=self.object) self._update_session_with_contact() return super().form_valid(form) - + def get_initial(self): """The initial value for the form (which is a formset here).""" db_object = from_database(form_class=self.form_class, obj=self.object) return db_object - + def get_context_data(self, **kwargs): - + context = super().get_context_data(**kwargs) context["email_sublabel_text"] = self._email_sublabel_text() @@ -259,7 +258,7 @@ class ContactProfileSetupView(ContactFormBaseView): context["confirm_changes"] = True return context - + def _email_sublabel_text(self): """Returns the lengthy sublabel for the email field""" help_url = public_site_url("help/account-management/#get-help-with-login.gov") diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index b07f0d53f..f93976138 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -819,4 +819,3 @@ class DomainRequestDeleteView(DomainRequestPermissionDeleteView): duplicates = [item for item, count in object_dict.items() if count > 1] return duplicates - diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 49d172971..3e5e10816 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -341,7 +341,6 @@ class ContactPermission(PermissionsLoginMixin): if not self.request.user.is_authenticated: return False - given_contact_pk = self.kwargs["pk"] # Grab the user in the DB to do a full object comparision, not just on ids @@ -351,13 +350,10 @@ class ContactPermission(PermissionsLoginMixin): if current_user.contact.pk != given_contact_pk: # Don't allow users to modify other users profiles return False - + # Check if the object at the id we're searching on actually exists requested_user_exists = User.objects.filter(pk=current_user.pk).exists() - requested_contact_exists = Contact.objects.filter( - user=current_user.pk, - pk=given_contact_pk - ).exists() + requested_contact_exists = Contact.objects.filter(user=current_user.pk, pk=given_contact_pk).exists() if not requested_user_exists or not requested_contact_exists: return False From 56e21f4c8ef33421fbec68ad3effe57e9ba02239 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 14 May 2024 14:30:48 -0600 Subject: [PATCH 031/214] Remove old code --- src/registrar/forms/domain_request_wizard.py | 2 +- src/registrar/forms/utility/wizard_form_helper.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index dd29df523..9d16a30de 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -150,7 +150,7 @@ class OrganizationContactForm(RegistrarForm): """Require something to be selected when this is a federal agency.""" federal_agency = self.cleaned_data.get("federal_agency", None) # need the domain request object to know if this is federal - if hasattr(self, "domain_request") and self.domain_request is None: + if self.domain_request is None: # hmm, no saved domain request object?, default require the agency if not federal_agency: # no answer was selected diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index 9b8a7c4d8..2dd1a2b42 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -21,8 +21,7 @@ class RegistrarForm(forms.Form): def __init__(self, *args, **kwargs): kwargs.setdefault("label_suffix", "") # save a reference to a domain request object - if "domain_request" in kwargs: - self.domain_request = kwargs.pop("domain_request", None) + self.domain_request = kwargs.pop("domain_request", None) super(RegistrarForm, self).__init__(*args, **kwargs) From 21f5c43b34c28fbba80a3895ae4e058e186bb284 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 14 May 2024 15:12:39 -0600 Subject: [PATCH 032/214] Add success message --- src/djangooidc/views.py | 46 ++++++++++++++++++++-------------- src/registrar/views/contact.py | 9 ++++++- 2 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index 7b5c58527..bf5fe379e 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -101,25 +101,9 @@ def login_callback(request): user = authenticate(request=request, **userinfo) is_new_user = request.session.get("is_new_user", False) if user: - should_update_user = False - # Fixture users kind of exist in a superposition of verification types, - # because while the system "verified" them, if they login, - # we don't know how the user themselves was verified through login.gov until - # they actually try logging in. This edge-case only matters in non-production environments. - fixture_user = User.VerificationTypeChoices.FIXTURE_USER - is_fixture_user = user.verification_type and user.verification_type == fixture_user - - # Set the verification type if it doesn't already exist or if its a fixture user - if not user.verification_type or is_fixture_user: - user.set_user_verification_type() - should_update_user = True - - if is_new_user: - user.finished_setup = False - should_update_user = True - - if should_update_user: - user.save() + # Set login metadata about this user + # (verification_type for instance) + _set_authenticated_user_metadata(user, is_new_user) login(request, user) @@ -149,6 +133,30 @@ def login_callback(request): return error_page(request, err) +def _set_authenticated_user_metadata(user, is_new_user): + """Does checks on the recieved authenticated user from login_callback, + and updates fields accordingly. U""" + should_update_user = False + # Fixture users kind of exist in a superposition of verification types, + # because while the system "verified" them, if they login, + # we don't know how the user themselves was verified through login.gov until + # they actually try logging in. This edge-case only matters in non-production environments. + fixture_user = User.VerificationTypeChoices.FIXTURE_USER + is_fixture_user = user.verification_type and user.verification_type == fixture_user + + # Set the verification type if it doesn't already exist or if its a fixture user + if not user.verification_type or is_fixture_user: + user.set_user_verification_type() + should_update_user = True + + if is_new_user: + user.finished_setup = False + should_update_user = True + + if should_update_user: + user.save() + + def _requires_step_up_auth(userinfo): """if User.needs_identity_verification and step_up_acr_value not in ial returned from callback, return True""" diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index 322630b07..a0b99c4a0 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -2,6 +2,7 @@ from waffle.decorators import waffle_flag from urllib.parse import urlencode, urlunparse, urlparse, quote from django.urls import NoReverseMatch, reverse from registrar.forms.contact import ContactForm +from django.contrib.messages.views import SuccessMessageMixin from registrar.models.contact import Contact from registrar.templatetags.url_helpers import public_site_url from registrar.views.utility.permission_views import ContactPermissionView @@ -17,10 +18,13 @@ import logging logger = logging.getLogger(__name__) -class BaseContactView(ContactPermissionView): +class BaseContactView(SuccessMessageMixin, ContactPermissionView): """Provides a base view for the contact object. On get, the contact is saved in the session and on self.object.""" + def get_success_message(self): + return "Contact updated successfully." + def get(self, request, *args, **kwargs): """Sets the current contact in cache, defines the current object as self.object then returns render_to_response""" @@ -108,6 +112,9 @@ class ContactProfileSetupView(ContactFormBaseView): COMPLETE_SETUP = "complete_setup" TO_SPECIFIC_PAGE = "domain_request" + def get_success_message(self): + return "Your profile has been successfully updated." + # TODO - refactor @waffle_flag("profile_feature") @method_decorator(csrf_protect) From f06620cef468d07bf200cef063c5c3ede04ae0bb Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 14 May 2024 15:18:10 -0600 Subject: [PATCH 033/214] Add nosec on mark_safe We directly control help_url, so this is not a problem --- src/registrar/views/contact.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index a0b99c4a0..de3525efb 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -273,4 +273,4 @@ class ContactProfileSetupView(ContactFormBaseView): "We recommend using your work email for your .gov account. " "If the wrong email is displayed below, you’ll need to update your Login.gov account " f'and log back in. Get help with your Login.gov account.' - ) + ) # nosec From 6c5e05f89b7924323b6c56bb7021599b086fcbae Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 15 May 2024 11:13:03 -0600 Subject: [PATCH 034/214] Tooltips, style refinement --- src/registrar/assets/js/get-gov.js | 51 ++++++++++------- src/registrar/assets/sass/_theme/_base.scss | 10 ++++ .../assets/sass/_theme/_cisa_colors.scss | 1 + .../assets/sass/_theme/_fieldsets.scss | 16 ++++++ .../assets/sass/_theme/_headers.scss | 14 +++++ .../assets/sass/_theme/_tooltips.scss | 7 +++ src/registrar/assets/sass/_theme/styles.scss | 1 + src/registrar/templates/base.html | 10 ++-- .../templates/finish_contact_setup.html | 55 ++++++++++++++----- src/registrar/templates/includes/footer.html | 2 + .../templates/includes/gov_extended_logo.html | 17 ++++++ .../includes/label_with_edit_button.html | 3 +- src/registrar/views/contact.py | 6 +- 13 files changed, 148 insertions(+), 45 deletions(-) create mode 100644 src/registrar/assets/sass/_theme/_headers.scss create mode 100644 src/registrar/templates/includes/gov_extended_logo.html diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 98a08fc2c..d18129456 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -868,31 +868,42 @@ function hideDeletedForms() { } } - function handleFullNameField(fieldName, nameFields) { + function handleFullNameField(fieldName) { // Remove the display-none class from the nearest parent div let fieldId = getInputFieldId(fieldName) let inputField = document.querySelector(fieldId); + let nameFieldset = document.querySelector("#contact-full-name-fieldset"); + if (nameFieldset){ + nameFieldset.classList.remove("display-none"); + } + if (inputField) { - // Show each name field - nameFields.forEach(function(fieldName) { - let nameId = getInputFieldId(fieldName) - let nameField = document.querySelector(nameId); - if (nameField){ - let parentDiv = nameField.closest("div"); - if (parentDiv) { - parentDiv.classList.remove("display-none"); + let readonlyId = getReadonlyFieldId(fieldName) + let readonlyField = document.querySelector(readonlyId) + if (readonlyField) { + // Update the element's xlink:href attribute + let useElement = readonlyField.querySelector("use"); + if (useElement) { + let currentHref = useElement.getAttribute("xlink:href"); + let parts = currentHref.split("#"); + + // Update the icon reference to the info icon + if (parts.length > 1) { + parts[1] = "info"; + useElement.setAttribute("xlink:href", parts.join("#")); + + // Change the color to => $dhs-dark-gray-60 + useElement.closest('svg').style.fill = '#444547'; } } - }); - - // Remove the "full_name" field - inputFieldParentDiv = inputField.closest("div"); - if (inputFieldParentDiv) { - inputFieldParentDiv.remove(); + + let parentDiv = readonlyField.closest("div"); + if (parentDiv) { + parentDiv.classList.toggle("overlapped-full-name-field"); + } } } - } function handleEditButtonClick(fieldName, button){ @@ -901,8 +912,7 @@ function hideDeletedForms() { button.disabled = true if (fieldName == "full_name"){ - let nameFields = ["first_name", "middle_name", "last_name"] - handleFullNameField(fieldName, nameFields); + handleFullNameField(fieldName); }else { showInputFieldHideReadonlyField(fieldName, button); } @@ -941,9 +951,10 @@ function hideDeletedForms() { // or if any of its associated fields do - show all name related fields. // Otherwise, just show the problematic field. if (fieldName == "full_name"){ - handleFullNameField(fieldName, nameFields); + handleFullNameField(fieldName); }else if (nameFields.includes(fieldName)){ - handleFullNameField("full_name", nameFields); + handleFullNameField("full_name"); + button.click() } else { button.click() diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index beb63cdd1..2cdaefb7d 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -170,3 +170,13 @@ abbr[title] { height: 1.25em !important; } } + +// todo - move this to a better location + better name +@media (min-width: 800px){ + .overlapped-full-name-field { + position: absolute; + top: 325px; + background-color: white; + padding-right: 8px; + } +} \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_cisa_colors.scss b/src/registrar/assets/sass/_theme/_cisa_colors.scss index 7466a3490..23ecf7989 100644 --- a/src/registrar/assets/sass/_theme/_cisa_colors.scss +++ b/src/registrar/assets/sass/_theme/_cisa_colors.scss @@ -46,6 +46,7 @@ $dhs-gray-10: #fcfdfd; /*--- Dark Gray ---*/ $dhs-dark-gray-90: #040404; +$dhs-dark-gray-85: #1b1b1b; $dhs-dark-gray-80: #19191a; $dhs-dark-gray-70: #2f2f30; $dhs-dark-gray-60: #444547; diff --git a/src/registrar/assets/sass/_theme/_fieldsets.scss b/src/registrar/assets/sass/_theme/_fieldsets.scss index c60080cb9..5cdd52026 100644 --- a/src/registrar/assets/sass/_theme/_fieldsets.scss +++ b/src/registrar/assets/sass/_theme/_fieldsets.scss @@ -8,3 +8,19 @@ fieldset { fieldset:not(:first-child) { margin-top: units(2); } + +fieldset.registrar-fieldset__contact { + border-width: 2px; + border-left: none; + border-right: none; + border-bottom: none; + padding-bottom: 0; +} + +@media (max-width: 800px){ + fieldset.registrar-fieldset__contact { + padding: 0; + margin: 0; + border: none; + } +} \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_headers.scss b/src/registrar/assets/sass/_theme/_headers.scss new file mode 100644 index 000000000..36868fdd3 --- /dev/null +++ b/src/registrar/assets/sass/_theme/_headers.scss @@ -0,0 +1,14 @@ +@use "uswds-core" as *; +@use "cisa_colors" as *; + +.usa-logo button { + color: #{$dhs-dark-gray-85}; + font-weight: 700; + font-family: family('sans'); + font-size: 1.6rem; + line-height: 1.1; +} + +.usa-logo button.usa-button--unstyled.disabled-button:hover{ + color: #{$dhs-dark-gray-85}; +} \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_tooltips.scss b/src/registrar/assets/sass/_theme/_tooltips.scss index 04c6f3cda..203bb4f85 100644 --- a/src/registrar/assets/sass/_theme/_tooltips.scss +++ b/src/registrar/assets/sass/_theme/_tooltips.scss @@ -24,3 +24,10 @@ text-align: center !important; } } + +.usa-tooltip--registrar-logo .usa-tooltip__body { + max-width: 50px !important; + font-weight: 400 !important; + white-space: normal; + text-align: center; +} \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss index 64b113a29..e24618a23 100644 --- a/src/registrar/assets/sass/_theme/styles.scss +++ b/src/registrar/assets/sass/_theme/styles.scss @@ -20,6 +20,7 @@ @forward "tables"; @forward "sidenav"; @forward "register-form"; +@forward "_headers"; /*-------------------------------------------------- --- Admin ---------------------------------*/ diff --git a/src/registrar/templates/base.html b/src/registrar/templates/base.html index c0702e78f..3b541d946 100644 --- a/src/registrar/templates/base.html +++ b/src/registrar/templates/base.html @@ -138,11 +138,7 @@
{% block logo %} - + {% include "includes/gov_extended_logo.html" with logo_clickable=True %} {% endblock %}
@@ -199,7 +195,9 @@
{% endblock wrapper%} - {% include "includes/footer.html" %} + {% block footer %} + {% include "includes/footer.html" with show_manage_your_domains=True %} + {% endblock footer %}
{% block init_js %}{% endblock %}{# useful for vars and other initializations #} diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_contact_setup.html index 68e860267..301f5837a 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_contact_setup.html @@ -1,16 +1,36 @@ {% extends "base.html" %} {% load static form_helpers url_helpers field_helpers %} -{% block title %} Finish setting up your profile {% endblock %} +{% block title %} Finish setting up your profile | {% endblock %} + +{# Disable the redirect #} +{% block logo %} + {% include "includes/gov_extended_logo.html" with logo_clickable=False %} +{% endblock %} {% block content %}
- {% include "includes/form_errors.html" with form=form %} + {% comment %} - Repurposed from domain_request_form.html + Form success messages. {% endcomment %} + {% if messages %} + {% for message in messages %} +
+
+ {{ message }} +
+
+ {% endfor %} + {% endif %} + + {% comment %} + Repurposed from domain_request_form.html. + Form errors. + {% endcomment %} + {% include "includes/form_errors.html" with form=form %} {% for outer in forms %} {% if outer|isformset %} {% for inner in outer.forms %} @@ -48,19 +68,21 @@ {% with show_edit_button=True show_readonly=True group_classes="usa-form-readonly padding-top-2" %} {% input_with_errors form.full_name %} {% endwith %} - - {% with show_edit_button=True show_readonly=True group_classes="usa-form-readonly padding-top-2 display-none" %} - {% input_with_errors form.first_name %} - {% endwith %} - - {% with show_edit_button=True show_readonly=True group_classes="usa-form-readonly padding-top-2 display-none" %} - {% input_with_errors form.middle_name %} - {% endwith %} - - {% with show_edit_button=True show_readonly=True group_classes="usa-form-readonly padding-top-2 display-none"%} - {% input_with_errors form.last_name %} - {% endwith %} + + {# This field doesn't have the readonly button but it has common design elements from it #} {% with show_readonly=True add_class="display-none" group_classes="usa-form-readonly padding-top-2 bold-usa-label" sublabel_text=email_sublabel_text %} {% input_with_errors form.email %} @@ -97,3 +119,6 @@ {% endblock content %} +{% block footer %} + {% include "includes/footer.html" %} +{% endblock footer %} \ No newline at end of file diff --git a/src/registrar/templates/includes/footer.html b/src/registrar/templates/includes/footer.html index 303c0ef74..5e10955e4 100644 --- a/src/registrar/templates/includes/footer.html +++ b/src/registrar/templates/includes/footer.html @@ -26,10 +26,12 @@ >
\ No newline at end of file diff --git a/src/registrar/views/contact.py b/src/registrar/views/contact.py index de3525efb..257f0db01 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/contact.py @@ -22,7 +22,8 @@ class BaseContactView(SuccessMessageMixin, ContactPermissionView): """Provides a base view for the contact object. On get, the contact is saved in the session and on self.object.""" - def get_success_message(self): + def get_success_message(self, cleaned_data): + """Content of the returned success message""" return "Contact updated successfully." def get(self, request, *args, **kwargs): @@ -112,7 +113,8 @@ class ContactProfileSetupView(ContactFormBaseView): COMPLETE_SETUP = "complete_setup" TO_SPECIFIC_PAGE = "domain_request" - def get_success_message(self): + def get_success_message(self, cleaned_data): + """Content of the returned success message""" return "Your profile has been successfully updated." # TODO - refactor From b2e52b52676ff7d56450d81625cb8bb4998a7c20 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 15 May 2024 11:28:56 -0600 Subject: [PATCH 035/214] Pixel adjustment --- src/registrar/assets/sass/_theme/_base.scss | 2 +- src/registrar/assets/sass/_theme/_fieldsets.scss | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index 2cdaefb7d..3fe5c6f68 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -175,7 +175,7 @@ abbr[title] { @media (min-width: 800px){ .overlapped-full-name-field { position: absolute; - top: 325px; + top: 337px; background-color: white; padding-right: 8px; } diff --git a/src/registrar/assets/sass/_theme/_fieldsets.scss b/src/registrar/assets/sass/_theme/_fieldsets.scss index 5cdd52026..da900f128 100644 --- a/src/registrar/assets/sass/_theme/_fieldsets.scss +++ b/src/registrar/assets/sass/_theme/_fieldsets.scss @@ -17,6 +17,12 @@ fieldset.registrar-fieldset__contact { padding-bottom: 0; } +@media (min-width: 800px) { + fieldset.registrar-fieldset__contact { + margin-top: 28px; + } +} + @media (max-width: 800px){ fieldset.registrar-fieldset__contact { padding: 0; From 04ced13eb6bc4468e40b22cbf9d8503058749f95 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 15 May 2024 12:08:53 -0600 Subject: [PATCH 036/214] Minor tweaks --- src/registrar/assets/js/get-gov.js | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index d18129456..315ff38f4 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -890,7 +890,7 @@ function hideDeletedForms() { // Update the icon reference to the info icon if (parts.length > 1) { - parts[1] = "info"; + parts[1] = "info_outline"; useElement.setAttribute("xlink:href", parts.join("#")); // Change the color to => $dhs-dark-gray-60 @@ -938,6 +938,7 @@ function hideDeletedForms() { } function showInputOnErrorFields(){ + let fullNameButtonClicked = false document.addEventListener('DOMContentLoaded', function() { document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { let fieldIdParts = button.id.split("__") @@ -947,18 +948,26 @@ function hideDeletedForms() { let errorMessage = document.querySelector(`#id_${fieldName}__error-message`); if (errorMessage) { let nameFields = ["first_name", "middle_name", "last_name"] + + button.click() + // If either the full_name field errors out, // or if any of its associated fields do - show all name related fields. // Otherwise, just show the problematic field. - if (fieldName == "full_name"){ - handleFullNameField(fieldName); - }else if (nameFields.includes(fieldName)){ - handleFullNameField("full_name"); - button.click() - } - else { - button.click() + if (nameFields.includes(fieldName) && !fullNameButtonClicked){ + fullNameButton = document.querySelector("#full_name__edit-button") + if (fullNameButton) { + fullNameButton.click() + fullNameButtonClicked = true + } + + let readonlyId = getReadonlyFieldId("full_name"); + let readonlyField = document.querySelector(readonlyId); + if (readonlyField) { + readonlyField.classList.toggle("overlapped-full-name-field"); + } } + } } }); From bb42732c80a2cf0f018480da47a14d0e6d151432 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Wed, 15 May 2024 15:24:19 -0600 Subject: [PATCH 037/214] Cleanup Pushing old commit content to save work already done --- src/registrar/config/urls.py | 4 +- src/registrar/forms/domain.py | 1 - .../{contact.py => finish_user_setup.py} | 4 +- .../forms/utility/wizard_form_helper.py | 1 - .../models/utility/generic_helper.py | 27 +- src/registrar/registrar_middleware.py | 68 ++-- src/registrar/views/__init__.py | 4 +- .../{contact.py => finish_user_setup.py} | 323 ++++++++---------- src/registrar/views/utility/mixins.py | 14 +- 9 files changed, 211 insertions(+), 235 deletions(-) rename src/registrar/forms/{contact.py => finish_user_setup.py} (94%) rename src/registrar/views/{contact.py => finish_user_setup.py} (63%) diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 596e5c3d2..215e1740e 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -104,8 +104,8 @@ urlpatterns = [ # We embed the current user ID here, but we have a permission check # that ensures the user is who they say they are. "finish-user-setup/", - views.ContactProfileSetupView.as_view(), - name="finish-contact-profile-setup", + views.FinishUserSetupView.as_view(), + name="finish-user-profile-setup", ), path( "domain-request//edit/", diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index db247ad21..da1462bdb 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -203,7 +203,6 @@ NameserverFormset = formset_factory( ) -# TODO - refactor, wait until daves PR class ContactForm(forms.ModelForm): """Form for updating contacts.""" diff --git a/src/registrar/forms/contact.py b/src/registrar/forms/finish_user_setup.py similarity index 94% rename from src/registrar/forms/contact.py rename to src/registrar/forms/finish_user_setup.py index 9a752bab4..46b75090f 100644 --- a/src/registrar/forms/contact.py +++ b/src/registrar/forms/finish_user_setup.py @@ -2,8 +2,8 @@ from django import forms from phonenumber_field.formfields import PhoneNumberField # type: ignore -class ContactForm(forms.Form): - """Form for adding or editing a contact""" +class FinishUserSetupForm(forms.Form): + """Form for adding or editing user information""" def clean(self): cleaned_data = super().clean() diff --git a/src/registrar/forms/utility/wizard_form_helper.py b/src/registrar/forms/utility/wizard_form_helper.py index 2dd1a2b42..2ae50f908 100644 --- a/src/registrar/forms/utility/wizard_form_helper.py +++ b/src/registrar/forms/utility/wizard_form_helper.py @@ -22,7 +22,6 @@ class RegistrarForm(forms.Form): kwargs.setdefault("label_suffix", "") # save a reference to a domain request object self.domain_request = kwargs.pop("domain_request", None) - super(RegistrarForm, self).__init__(*args, **kwargs) def to_database(self, obj: DomainRequest | Contact): diff --git a/src/registrar/models/utility/generic_helper.py b/src/registrar/models/utility/generic_helper.py index 861fcea07..5f92b5953 100644 --- a/src/registrar/models/utility/generic_helper.py +++ b/src/registrar/models/utility/generic_helper.py @@ -2,7 +2,8 @@ import time import logging - +from typing import Any +from urllib.parse import urlparse, urlunparse, urlencode logger = logging.getLogger(__name__) @@ -286,3 +287,27 @@ def from_database(form_class, obj): if obj is None: return {} return {name: getattr(obj, name) for name in form_class.declared_fields.keys()} # type: ignore + + +def replace_url_queryparams(url_to_modify: str, query_params: dict[Any, list]): + """ + Replaces the query parameters of a given URL. + + Args: + url_to_modify (str): The URL whose query parameters need to be modified. + query_params (dict): Dictionary of query parameters to use. + + Returns: + str: The modified URL with the updated query parameters. + """ + + # Split the URL into parts + url_parts = list(urlparse(url_to_modify)) + + # Modify the query param bit + url_parts[4] = urlencode(query_params) + + # Reassemble the URL + new_url = urlunparse(url_parts) + + return new_url \ No newline at end of file diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index 8dca06019..5707c4d5d 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -7,6 +7,8 @@ from django.urls import reverse from django.http import HttpResponseRedirect from waffle.decorators import flag_is_active +from registrar.models.utility.generic_helper import replace_url_queryparams + class CheckUserProfileMiddleware: """ @@ -28,49 +30,47 @@ class CheckUserProfileMiddleware: # Check that the user is "opted-in" to the profile feature flag has_profile_feature_flag = flag_is_active(request, "profile_feature") - # If they aren't, skip this entirely + # If they aren't, skip this check entirely if not has_profile_feature_flag: return None # Check if setup is not finished finished_setup = hasattr(request.user, "finished_setup") and request.user.finished_setup if request.user.is_authenticated and not finished_setup: - setup_page = reverse("finish-contact-profile-setup", kwargs={"pk": request.user.contact.pk}) - logout_page = reverse("logout") - excluded_pages = [ - setup_page, - logout_page, - ] - custom_redirect = None - - # In some cases, we don't want to redirect to home. - # This handles that. - if request.path == "/request/": - # This can be generalized if need be, but for now lets keep this easy to read. - custom_redirect = "domain-request:" - - # Don't redirect on excluded pages (such as the setup page itself) - if not any(request.path.startswith(page) for page in excluded_pages): - # Preserve the original query parameters, and coerce them into a dict - query_params = parse_qs(request.META["QUERY_STRING"]) - - if custom_redirect is not None: - # Set the redirect value to our redirect location - query_params["redirect"] = custom_redirect - - if query_params: - # Split the URL into parts - setup_page_parts = list(urlparse(setup_page)) - # Modify the query param bit - setup_page_parts[4] = urlencode(query_params) - # Reassemble the URL - setup_page = urlunparse(setup_page_parts) - - # Redirect to the setup page - return HttpResponseRedirect(setup_page) + return self._handle_setup_not_finished(request) # Continue processing the view return None + + def _handle_setup_not_finished(self, request): + setup_page = reverse("finish-user-profile-setup", kwargs={"pk": request.user.contact.pk}) + logout_page = reverse("logout") + excluded_pages = [ + setup_page, + logout_page, + ] + + # In some cases, we don't want to redirect to home. This handles that. + # Can easily be generalized if need be, but for now lets keep this easy to read. + custom_redirect = "domain-request:" if request.path == "/request/" else None + + # Don't redirect on excluded pages (such as the setup page itself) + if not any(request.path.startswith(page) for page in excluded_pages): + # Preserve the original query parameters, and coerce them into a dict + query_params = parse_qs(request.META["QUERY_STRING"]) + + if custom_redirect is not None: + # Set the redirect value to our redirect location + query_params["redirect"] = custom_redirect + + if query_params: + setup_page = replace_url_queryparams(setup_page, query_params) + + # Redirect to the setup page + return HttpResponseRedirect(setup_page) + else: + # Process the view as normal + return None class NoCacheMiddleware: diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index 692cfd4de..961d0d94b 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -14,8 +14,8 @@ from .domain import ( DomainInvitationDeleteView, DomainDeleteUserView, ) -from .contact import ( - ContactProfileSetupView, +from .finish_user_setup import ( + FinishUserSetupView, ) from .health import * from .index import * diff --git a/src/registrar/views/contact.py b/src/registrar/views/finish_user_setup.py similarity index 63% rename from src/registrar/views/contact.py rename to src/registrar/views/finish_user_setup.py index 257f0db01..f545b1be9 100644 --- a/src/registrar/views/contact.py +++ b/src/registrar/views/finish_user_setup.py @@ -1,13 +1,14 @@ +from enum import Enum from waffle.decorators import waffle_flag -from urllib.parse import urlencode, urlunparse, urlparse, quote +from urllib.parse import quote from django.urls import NoReverseMatch, reverse -from registrar.forms.contact import ContactForm +from registrar.forms.finish_user_setup import FinishUserSetupForm from django.contrib.messages.views import SuccessMessageMixin from registrar.models.contact import Contact from registrar.templatetags.url_helpers import public_site_url from registrar.views.utility.permission_views import ContactPermissionView from django.views.generic.edit import FormMixin -from registrar.models.utility.generic_helper import to_database, from_database +from registrar.models.utility.generic_helper import replace_url_queryparams, to_database, from_database from django.utils.safestring import mark_safe from django.utils.decorators import method_decorator @@ -19,27 +20,17 @@ logger = logging.getLogger(__name__) class BaseContactView(SuccessMessageMixin, ContactPermissionView): - """Provides a base view for the contact object. On get, the contact - is saved in the session and on self.object.""" def get_success_message(self, cleaned_data): """Content of the returned success message""" return "Contact updated successfully." def get(self, request, *args, **kwargs): - """Sets the current contact in cache, defines the current object as self.object - then returns render_to_response""" - self._set_contact(request) + self._update_object_and_session(request) context = self.get_context_data(object=self.object) return self.render_to_response(context) - def _set_contact(self, request): - """ - get contact from session cache or from db and set - to self.object - set session to self for downstream functions to - update session cache - """ + def _update_object_and_session(self, request): self.session = request.session contact_pk = "contact:" + str(self.kwargs.get("pk")) @@ -50,9 +41,9 @@ class BaseContactView(SuccessMessageMixin, ContactPermissionView): else: self.object = self.get_object() - self._update_session_with_contact() + self._refresh_session() - def _update_session_with_contact(self): + def _refresh_session(self): """ Set contact pk in the session cache """ @@ -63,46 +54,30 @@ class BaseContactView(SuccessMessageMixin, ContactPermissionView): class ContactFormBaseView(BaseContactView, FormMixin): """Adds a FormMixin to BaseContactView, and handles post""" - def post(self, request, *args, **kwargs): - """Form submission posts to this view. - - This post method harmonizes using BaseContactView and FormMixin - """ - # Set the current contact object in cache - self._set_contact(request) - - form = self.get_form() - - # Get the current form and validate it - return self.form_valid(form) if form.is_valid() else self.form_invalid(form) - def form_invalid(self, form): # updates session cache with contact - self._update_session_with_contact() + self._refresh_session() # superclass has the redirect return super().form_invalid(form) -class ContactProfileSetupView(ContactFormBaseView): +class FinishUserSetupView(ContactFormBaseView): """This view forces the user into providing additional details that we may have missed from Login.gov""" template_name = "finish_contact_setup.html" - form_class = ContactForm + form_class = FinishUserSetupForm model = Contact redirect_type = None - # TODO - make this an enum - class RedirectType: + class RedirectType(Enum): """ - Contains constants for each type of redirection. - Not an enum as we just need to track string values, - but we don't care about enforcing it. + Enums for each type of redirection. Enforces behaviour on `get_redirect_url()`. - HOME: We want to redirect to reverse("home") - - BACK_TO_SELF: We want to redirect back to reverse("finish-contact-profile-setup") + - BACK_TO_SELF: We want to redirect back to reverse("finish-user-profile-setup") - TO_SPECIFIC_PAGE: We want to redirect to the page specified in the queryparam "redirect" - COMPLETE_SETUP: Indicates that we want to navigate BACK_TO_SELF, but the subsequent redirect after the next POST should be either HOME or TO_SPECIFIC_PAGE @@ -113,146 +88,6 @@ class ContactProfileSetupView(ContactFormBaseView): COMPLETE_SETUP = "complete_setup" TO_SPECIFIC_PAGE = "domain_request" - def get_success_message(self, cleaned_data): - """Content of the returned success message""" - return "Your profile has been successfully updated." - - # TODO - refactor - @waffle_flag("profile_feature") - @method_decorator(csrf_protect) - def dispatch(self, request, *args, **kwargs): - """ - Handles dispatching of the view, applying CSRF protection and checking the 'profile_feature' flag. - - This method sets the redirect type based on the 'redirect' query parameter, - defaulting to BACK_TO_SELF if not provided. - It updates the session with the redirect view name if the redirect type is TO_SPECIFIC_PAGE. - - Returns: - HttpResponse: The response generated by the parent class's dispatch method. - """ - # Default redirect type - default_redirect = self.RedirectType.BACK_TO_SELF - - # Update redirect type based on the query parameter if present - redirect_type = request.GET.get("redirect", None) - - is_default = False - # We set this here rather than in .get so we don't override - # existing data if no queryparam is present. - if redirect_type is None: - is_default = True - redirect_type = default_redirect - - # Set the default if nothing exists already - if self.redirect_type is None: - self.redirect_type = redirect_type - - if not is_default: - default_redirects = [ - self.RedirectType.HOME, - self.RedirectType.COMPLETE_SETUP, - self.RedirectType.BACK_TO_SELF, - self.RedirectType.TO_SPECIFIC_PAGE, - ] - if redirect_type not in default_redirects: - self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE - request.session["profile_setup_redirect_viewname"] = redirect_type - else: - self.redirect_type = redirect_type - - return super().dispatch(request, *args, **kwargs) - - def get_redirect_url(self): - """ - Returns a URL string based on the current value of self.redirect_type. - - Depending on self.redirect_type, constructs a base URL and appends a - 'redirect' query parameter. Handles different redirection types such as - HOME, BACK_TO_SELF, COMPLETE_SETUP, and TO_SPECIFIC_PAGE. - - Returns: - str: The full URL with the appropriate query parameters. - """ - base_url = "" - query_params = {} - match self.redirect_type: - case self.RedirectType.HOME: - base_url = reverse("home") - case self.RedirectType.BACK_TO_SELF | self.RedirectType.COMPLETE_SETUP: - base_url = reverse("finish-contact-profile-setup", kwargs={"pk": self.object.pk}) - case self.RedirectType.TO_SPECIFIC_PAGE: - - # We only allow this session value to use viewnames, - # because otherwise this allows anyone to enter any value in here. - # This restricts what can be redirected to. - try: - desired_view = self.session["profile_setup_redirect_viewname"] - base_url = reverse(desired_view) - except NoReverseMatch as err: - logger.error(err) - logger.error("ContactProfileSetupView -> get_redirect_url -> Could not find specified page.") - base_url = reverse("home") - case _: - base_url = reverse("home") - - # Quote cleans up the value so that it can be used in a url - query_params["redirect"] = quote(self.redirect_type) - - # Parse the base URL - url_parts = list(urlparse(base_url)) - - # Update the query part of the URL - url_parts[4] = urlencode(query_params) - - # Construct the full URL with query parameters - full_url = urlunparse(url_parts) - return full_url - - def get_success_url(self): - """Redirect to the nameservers page for the domain.""" - redirect_url = self.get_redirect_url() - return redirect_url - - # TODO - delete session information - def post(self, request, *args, **kwargs): - """Form submission posts to this view. - - This post method harmonizes using BaseContactView and FormMixin - """ - - # Set the current contact object in cache - self._set_contact(request) - - form = self.get_form() - - # Get the current form and validate it - if form.is_valid(): - if "contact_setup_save_button" in request.POST: - # Logic for when the 'Save' button is clicked - self.redirect_type = self.RedirectType.COMPLETE_SETUP - elif "contact_setup_submit_button" in request.POST: - if "profile_setup_redirect_viewname" in self.session: - self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE - else: - self.redirect_type = self.RedirectType.HOME - - return self.form_valid(form) - else: - return self.form_invalid(form) - - def form_valid(self, form): - - completed_states = [self.RedirectType.TO_SPECIFIC_PAGE, self.RedirectType.HOME] - if self.redirect_type in completed_states: - self.request.user.finished_setup = True - self.request.user.save() - - to_database(form=form, obj=self.object) - self._update_session_with_contact() - - return super().form_valid(form) - def get_initial(self): """The initial value for the form (which is a formset here).""" db_object = from_database(form_class=self.form_class, obj=self.object) @@ -276,3 +111,133 @@ class ContactProfileSetupView(ContactFormBaseView): "If the wrong email is displayed below, you’ll need to update your Login.gov account " f'and log back in. Get help with your Login.gov account.' ) # nosec + + + def get_success_message(self, cleaned_data): + """Content of the returned success message""" + return "Your profile has been successfully updated." + + @waffle_flag("profile_feature") + @method_decorator(csrf_protect) + def dispatch(self, request, *args, **kwargs): + """ + Handles dispatching of the view, applying CSRF protection and checking the 'profile_feature' flag. + + This method sets the redirect type based on the 'redirect' query parameter, + defaulting to BACK_TO_SELF if not provided. + It updates the session with the redirect view name if the redirect type is TO_SPECIFIC_PAGE. + + Returns: + HttpResponse: The response generated by the parent class's dispatch method. + """ + + # Update redirect type based on the query parameter if present + redirect_type = request.GET.get("redirect", None) + + # We set this here rather than in .get so we don't override + # existing data if no queryparam is present. + is_default = redirect_type is None + if is_default: + # Set to the default + redirect_type = self.RedirectType.BACK_TO_SELF + self.redirect_type = redirect_type + else: + all_redirect_types = [r.value for r in self.RedirectType] + if redirect_type in all_redirect_types: + self.redirect_type = self.RedirectType(redirect_type) + else: + # If the redirect type is undefined, then we assume that + # we are specifying a particular page to redirect to. + self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE + + # Store the page that we want to redirect to for later use + request.session["redirect_viewname"] = str(redirect_type) + + return super().dispatch(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + """Form submission posts to this view. + + This post method harmonizes using BaseContactView and FormMixin + """ + + # Set the current object in cache + self._update_object_and_session(request) + + form = self.get_form() + + # Get the current form and validate it + if form.is_valid(): + if "contact_setup_save_button" in request.POST: + # Logic for when the 'Save' button is clicked + self.redirect_type = self.RedirectType.COMPLETE_SETUP + elif "contact_setup_submit_button" in request.POST: + if "redirect_viewname" in self.session: + self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE + else: + self.redirect_type = self.RedirectType.HOME + + return self.form_valid(form) + else: + return self.form_invalid(form) + + def form_valid(self, form): + """Saves the current contact to the database, and if the user is complete + with their setup, then we mark user.finished_setup to True.""" + completed_states = [self.RedirectType.TO_SPECIFIC_PAGE, self.RedirectType.HOME] + if self.redirect_type in completed_states: + self.request.user.finished_setup = True + self.request.user.save() + + to_database(form=form, obj=self.object) + self._refresh_session() + + return super().form_valid(form) + + def get_success_url(self): + """Redirect to the nameservers page for the domain.""" + redirect_url = self.get_redirect_url() + return redirect_url + + def get_redirect_url(self): + """ + Returns a URL string based on the current value of self.redirect_type. + + Depending on self.redirect_type, constructs a base URL and appends a + 'redirect' query parameter. Handles different redirection types such as + HOME, BACK_TO_SELF, COMPLETE_SETUP, and TO_SPECIFIC_PAGE. + + Returns: + str: The full URL with the appropriate query parameters. + """ + + # These redirect types redirect to the same page + self_redirect = [ + self.RedirectType.BACK_TO_SELF, + self.RedirectType.COMPLETE_SETUP + ] + + # Maps the redirect type to a URL + base_url = "" + try: + if self.redirect_type in self_redirect: + base_url = reverse("finish-user-profile-setup", kwargs={"pk": self.object.pk}) + elif self.redirect_type == self.RedirectType.TO_SPECIFIC_PAGE: + # We only allow this session value to use viewnames, + # because this restricts what can be redirected to. + desired_view = self.session["redirect_viewname"] + base_url = reverse(desired_view) + else: + base_url = reverse("home") + except NoReverseMatch as err: + logger.error(f"get_redirect_url -> Could not find the specified page. Err: {err}") + + query_params = {} + + # Quote cleans up the value so that it can be used in a url + query_params["redirect"] = quote(self.redirect_type.value) + + # Generate the full url from the given query params + full_url = replace_url_queryparams(base_url, query_params) + return full_url + diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 3e5e10816..6f5c08b98 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -358,19 +358,7 @@ class ContactPermission(PermissionsLoginMixin): if not requested_user_exists or not requested_contact_exists: return False - # Check if the user has an associated contact - associated_contacts = Contact.objects.filter(user=current_user) - associated_contacts_length = len(associated_contacts) - - if associated_contacts_length == 0: - # This means that the user trying to access this page - # is a different user than the contact holder. - return False - elif associated_contacts_length > 1: - # TODO - change this - raise ValueError("User has multiple connected contacts") - else: - return True + return True class DomainRequestPermissionWithdraw(PermissionsLoginMixin): From 5ed0a56d79b997ba956f271557ee41a615550f85 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Thu, 16 May 2024 09:50:08 -0600 Subject: [PATCH 038/214] Fix migration --- ...{0094_user_finished_setup.py => 0095_user_finished_setup.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/registrar/migrations/{0094_user_finished_setup.py => 0095_user_finished_setup.py} (83%) diff --git a/src/registrar/migrations/0094_user_finished_setup.py b/src/registrar/migrations/0095_user_finished_setup.py similarity index 83% rename from src/registrar/migrations/0094_user_finished_setup.py rename to src/registrar/migrations/0095_user_finished_setup.py index 660f950c0..87e247330 100644 --- a/src/registrar/migrations/0094_user_finished_setup.py +++ b/src/registrar/migrations/0095_user_finished_setup.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ("registrar", "0093_alter_publiccontact_unique_together"), + ("registrar", "0094_create_groups_v12"), ] operations = [ From 7f42ecb86786a9b9841aa5a01c7fc4b04b322d71 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Fri, 17 May 2024 13:03:08 -0700 Subject: [PATCH 039/214] Adding biz logic in --- src/registrar/assets/sass/_theme/_forms.scss | 7 +- .../templates/domain_request_form.html | 17 +++ .../templates/domain_request_review.html | 2 +- src/registrar/views/domain_request.py | 101 +++++++++++++++--- 4 files changed, 112 insertions(+), 15 deletions(-) diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index 058a9f6c8..bdef522eb 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -32,6 +32,11 @@ border-left: none; } +.incomplete { + color: red; + font-weight: bold; +} + legend.float-left-tablet + button.float-right-tablet { margin-top: .5rem; @include at-media('tablet') { @@ -52,4 +57,4 @@ legend.float-left-tablet + button.float-right-tablet { background-color: var(--body-fg); color: var(--close-button-hover-bg); } -} \ No newline at end of file +} diff --git a/src/registrar/templates/domain_request_form.html b/src/registrar/templates/domain_request_form.html index cde12ad80..405876da6 100644 --- a/src/registrar/templates/domain_request_form.html +++ b/src/registrar/templates/domain_request_form.html @@ -84,6 +84,13 @@ value="save_and_return" class="usa-button usa-button--outline" >Save and return to manage your domains + {% else %} + + {% block after_form_content %}{% endblock %} diff --git a/src/registrar/templates/domain_request_review.html b/src/registrar/templates/domain_request_review.html index 5f359e95f..88a9c128e 100644 --- a/src/registrar/templates/domain_request_review.html +++ b/src/registrar/templates/domain_request_review.html @@ -37,7 +37,7 @@ {% if step == Step.TRIBAL_GOVERNMENT %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.tribe_name|default:"Incomplete" %} + {% with title=form_titles|get_item:step value=domain_request.tribe_name|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% if domain_request.federally_recognized_tribe %}

Federally-recognized tribe

{% endif %} diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index f93976138..c233b01d0 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -374,25 +374,100 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): } return [key for key, value in history_dict.items() if value] + + def _form_complete(self): + # So in theory each part of the form individually should be already doing the check, correct? + # In theory, that means we just only need to check for the title pages that are completed which is + # Technically some of these don't even show up at all depending on which "state" its in or chosen + + # If we choose Federal -> check "Federal government branch (has defaults) + # If we choose Interstate -> check "About your organization" + # If we choose State -> check "Election office" (has default) + # If we choose Tribal -> check "Tribal name" and "Election office" + # County -> "Election office" + # City -> "Election office" + # Special district -> "Election office" and "About your organization" + # School district -> clears common + + + if ( + # (self.domain_request.tribe_name is None) + (self.domain_request.generic_org_type is None) + or (self.domain_request.tribe_name is None) + or (self.domain_request.federal_type is None) + or (self.domain_request.is_election_board is None) + or ( + self.domain_request.federal_agency is None + or self.domain_request.organization_name is None + or self.domain_request.address_line1 is None + or self.domain_request.city is None + or self.domain_request.state_territory is None + or self.domain_request.zipcode is None + or self.domain_request.urbanization is None + ) # organization contact + or (self.domain_request.about_your_organization is None) + or (self.domain_request.authorizing_official is None) + or ( + self.domain_request.current_websites.exists() or self.domain_request.requested_domain is None + ) # for current_sites + or (self.domain_request.requested_domain is None) # for dotgov_domain + or (self.domain_request.purpose is None) + or (self.domain_request.submitter is None) # your_contact + or (self.domain_request.other_contacts is None) + or ( + (self.domain_request.anything_else is None and self.domain_request.cisa_representative_email) + or self.domain_request.is_policy_acknowledged is None + ) # additional detail + or (self.domain_request.is_policy_acknowledged is None) # review + ): + # print("!!!!!! self.domain_request.tribe_name is", self.domain_request.tribe_name) + # context = self.get_context_data() + # context["forms"] = self.get_forms() + # context["form_is_not_complete"] = False + + return False + else: + # print("!!!!!! self.domain_request.tribe_name is", self.domain_request.tribe_name) + return True + + # return None + def get_context_data(self): """Define context for access on all wizard pages.""" # Build the submit button that we'll pass to the modal. modal_button = '" # Concatenate the modal header that we'll pass to the modal. - if self.domain_request.requested_domain: - modal_heading = "You are about to submit a domain request for " + str(self.domain_request.requested_domain) - else: - modal_heading = "You are about to submit an incomplete request" - return { - "form_titles": self.TITLES, - "steps": self.steps, - # Add information about which steps should be unlocked - "visited": self.storage.get("step_history", []), - "is_federal": self.domain_request.is_federal(), - "modal_button": modal_button, - "modal_heading": modal_heading, - } + # TODO: Still need to log! + context_stuff = {} + print("!!!!!!!!! before form complete") + print("!!!!!!!!! self.form_complete is", self._form_complete()) + if self._form_complete(): + print("!!!!!!!in form complete section") + context_stuff = { + "form_titles": self.TITLES, + "steps": self.steps, + # Add information about which steps should be unlocked + "visited": self.storage.get("step_history", []), + "is_federal": self.domain_request.is_federal(), + "modal_button": modal_button, + "modal_heading": "You are about to submit a domain request for " + + str(self.domain_request.requested_domain), + } + else: # form is not complete + print("!!!!!!! form is not complete") + context_stuff = { + "form_titles": self.TITLES, + "steps": self.steps, + # Add information about which steps should be unlocked + "visited": self.storage.get("step_history", []), + "is_federal": self.domain_request.is_federal(), + # "modal_button": We'll have to set some kind of go back button + # And fix wording in text for domain_request_form + "modal_heading": "You can’t submit this request", + } + + return context_stuff def get_step_list(self) -> list: """Dynamically generated list of steps in the form wizard.""" From 9d576ed1cbbe24f0e225cedcdd9c06a92c054326 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 20 May 2024 10:15:53 -0600 Subject: [PATCH 040/214] Simplify --- src/registrar/views/finish_user_setup.py | 32 +++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/registrar/views/finish_user_setup.py b/src/registrar/views/finish_user_setup.py index f545b1be9..75eddb011 100644 --- a/src/registrar/views/finish_user_setup.py +++ b/src/registrar/views/finish_user_setup.py @@ -132,26 +132,18 @@ class FinishUserSetupView(ContactFormBaseView): """ # Update redirect type based on the query parameter if present - redirect_type = request.GET.get("redirect", None) + redirect_type = request.GET.get("redirect", self.RedirectType.BACK_TO_SELF) - # We set this here rather than in .get so we don't override - # existing data if no queryparam is present. - is_default = redirect_type is None - if is_default: - # Set to the default - redirect_type = self.RedirectType.BACK_TO_SELF - self.redirect_type = redirect_type + all_redirect_types = [r.value for r in self.RedirectType] + if redirect_type in all_redirect_types: + self.redirect_type = self.RedirectType(redirect_type) else: - all_redirect_types = [r.value for r in self.RedirectType] - if redirect_type in all_redirect_types: - self.redirect_type = self.RedirectType(redirect_type) - else: - # If the redirect type is undefined, then we assume that - # we are specifying a particular page to redirect to. - self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE + # If the redirect type is undefined, then we assume that + # we are specifying a particular page to redirect to. + self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE - # Store the page that we want to redirect to for later use - request.session["redirect_viewname"] = str(redirect_type) + # Store the page that we want to redirect to for later use + request.session["redirect_viewname"] = str(redirect_type) return super().dispatch(request, *args, **kwargs) @@ -168,6 +160,12 @@ class FinishUserSetupView(ContactFormBaseView): # Get the current form and validate it if form.is_valid(): + + completed_states = [self.RedirectType.TO_SPECIFIC_PAGE, self.RedirectType.HOME] + if self.redirect_type in completed_states: + self.request.user.finished_setup = True + self.request.user.save() + if "contact_setup_save_button" in request.POST: # Logic for when the 'Save' button is clicked self.redirect_type = self.RedirectType.COMPLETE_SETUP From 9a6ccc1c44d136ff12e1a2ff910f705dd3b5f266 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Mon, 20 May 2024 11:25:32 -0600 Subject: [PATCH 041/214] Replace form with modelform Pull in content from related PR and refactor around it --- src/registrar/assets/js/get-gov.js | 29 +-- .../assets/sass/_theme/_fieldsets.scss | 23 +- src/registrar/config/urls.py | 4 +- src/registrar/forms/finish_user_setup.py | 48 ---- src/registrar/forms/user_profile.py | 30 ++- src/registrar/models/contact.py | 7 - ...t_setup.html => finish_profile_setup.html} | 18 +- src/registrar/views/__init__.py | 5 +- src/registrar/views/finish_user_setup.py | 241 ------------------ src/registrar/views/user_profile.py | 188 +++++++++++++- 10 files changed, 230 insertions(+), 363 deletions(-) delete mode 100644 src/registrar/forms/finish_user_setup.py rename src/registrar/templates/{finish_contact_setup.html => finish_profile_setup.html} (82%) delete mode 100644 src/registrar/views/finish_user_setup.py diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 315ff38f4..2fd534f04 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -873,35 +873,16 @@ function hideDeletedForms() { let fieldId = getInputFieldId(fieldName) let inputField = document.querySelector(fieldId); - let nameFieldset = document.querySelector("#contact-full-name-fieldset"); + let nameFieldset = document.querySelector("#profile-name-fieldset"); if (nameFieldset){ nameFieldset.classList.remove("display-none"); } if (inputField) { - let readonlyId = getReadonlyFieldId(fieldName) - let readonlyField = document.querySelector(readonlyId) - if (readonlyField) { - // Update the element's xlink:href attribute - let useElement = readonlyField.querySelector("use"); - if (useElement) { - let currentHref = useElement.getAttribute("xlink:href"); - let parts = currentHref.split("#"); - - // Update the icon reference to the info icon - if (parts.length > 1) { - parts[1] = "info_outline"; - useElement.setAttribute("xlink:href", parts.join("#")); - - // Change the color to => $dhs-dark-gray-60 - useElement.closest('svg').style.fill = '#444547'; - } - } - - let parentDiv = readonlyField.closest("div"); - if (parentDiv) { - parentDiv.classList.toggle("overlapped-full-name-field"); - } + // Remove the "full_name" field + inputFieldParentDiv = inputField.closest("div"); + if (inputFieldParentDiv) { + inputFieldParentDiv.remove(); } } } diff --git a/src/registrar/assets/sass/_theme/_fieldsets.scss b/src/registrar/assets/sass/_theme/_fieldsets.scss index da900f128..7ad0a2a82 100644 --- a/src/registrar/assets/sass/_theme/_fieldsets.scss +++ b/src/registrar/assets/sass/_theme/_fieldsets.scss @@ -10,23 +10,8 @@ fieldset:not(:first-child) { } fieldset.registrar-fieldset__contact { - border-width: 2px; - border-left: none; - border-right: none; - border-bottom: none; - padding-bottom: 0; + // This fieldset is for SR purposes only + border: 0; + margin: 0; + padding: 0; } - -@media (min-width: 800px) { - fieldset.registrar-fieldset__contact { - margin-top: 28px; - } -} - -@media (max-width: 800px){ - fieldset.registrar-fieldset__contact { - padding: 0; - margin: 0; - border: none; - } -} \ No newline at end of file diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 5ed9adae0..2d4c33569 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -103,8 +103,8 @@ urlpatterns = [ path( # We embed the current user ID here, but we have a permission check # that ensures the user is who they say they are. - "finish-user-setup/", - views.FinishUserSetupView.as_view(), + "finish-profile-setup/", + views.FinishProfileSetupView.as_view(), name="finish-user-profile-setup", ), path( diff --git a/src/registrar/forms/finish_user_setup.py b/src/registrar/forms/finish_user_setup.py deleted file mode 100644 index 46b75090f..000000000 --- a/src/registrar/forms/finish_user_setup.py +++ /dev/null @@ -1,48 +0,0 @@ -from django import forms -from phonenumber_field.formfields import PhoneNumberField # type: ignore - - -class FinishUserSetupForm(forms.Form): - """Form for adding or editing user information""" - - def clean(self): - cleaned_data = super().clean() - # Remove the full name property - if "full_name" in cleaned_data: - # Delete the full name element as its purely decorative. - # We include it as a normal Charfield for all the advantages - # and utility that it brings, but we're playing pretend. - del cleaned_data["full_name"] - return cleaned_data - - full_name = forms.CharField( - label="Full name", - error_messages={"required": "Enter your full name"}, - ) - first_name = forms.CharField( - label="First name / given name", - error_messages={"required": "Enter your first name / given name."}, - ) - middle_name = forms.CharField( - required=False, - label="Middle name (optional)", - ) - last_name = forms.CharField( - label="Last name / family name", - error_messages={"required": "Enter your last name / family name."}, - ) - title = forms.CharField( - label="Title or role in your organization", - error_messages={ - "required": ("Enter your title or role in your organization (e.g., Chief Information Officer).") - }, - ) - email = forms.EmailField( - label="Organization email", - required=False, - max_length=None, - ) - phone = PhoneNumberField( - label="Phone", - error_messages={"invalid": "Enter a valid 10-digit phone number.", "required": "Enter your phone number."}, - ) diff --git a/src/registrar/forms/user_profile.py b/src/registrar/forms/user_profile.py index 036b03751..abc90d195 100644 --- a/src/registrar/forms/user_profile.py +++ b/src/registrar/forms/user_profile.py @@ -55,6 +55,34 @@ class UserProfileForm(forms.ModelForm): "required": "Enter your email address in the required format, like name@example.com." } self.fields["phone"].error_messages["required"] = "Enter your phone number." - self.domainInfo = None DomainHelper.disable_field(self.fields["email"], disable_required=True) + + +class FinishSetupProfileForm(UserProfileForm): + """Form for updating user profile.""" + + full_name = forms.CharField(required=True, label="Full name") + + def clean(self): + cleaned_data = super().clean() + # Remove the full name property + if "full_name" in cleaned_data: + # Delete the full name element as its purely decorative. + # We include it as a normal Charfield for all the advantages + # and utility that it brings, but we're playing pretend. + del cleaned_data["full_name"] + return cleaned_data + + def __init__(self, *args, **kwargs): + """Override the inerited __init__ method to update the fields.""" + + super().__init__(*args, **kwargs) + + # Set custom form label for email + self.fields["email"].label = "Organization email" + self.fields["title"].label = "Title or role in your organization" + + # Define the "full_name" value + if self.instance and hasattr(self.instance, 'full_name'): + self.fields["full_name"].initial = self.instance.get_formatted_name() diff --git a/src/registrar/models/contact.py b/src/registrar/models/contact.py index 716849b32..0f55ed863 100644 --- a/src/registrar/models/contact.py +++ b/src/registrar/models/contact.py @@ -102,13 +102,6 @@ class Contact(TimeStampedModel): names = [n for n in [self.first_name, self.middle_name, self.last_name] if n] return " ".join(names) if names else "Unknown" - @property - def full_name(self): - """ - Returns the full name (first_name, middle_name, last_name) of this contact. - """ - return self.get_formatted_name() - def has_contact_info(self): return bool(self.title or self.email or self.phone) diff --git a/src/registrar/templates/finish_contact_setup.html b/src/registrar/templates/finish_profile_setup.html similarity index 82% rename from src/registrar/templates/finish_contact_setup.html rename to src/registrar/templates/finish_profile_setup.html index 301f5837a..a8e16724e 100644 --- a/src/registrar/templates/finish_contact_setup.html +++ b/src/registrar/templates/finish_profile_setup.html @@ -62,25 +62,17 @@ Your contact information - - {# TODO: if an error is thrown here or edit clicked, show first last and middle fields #} - {# Also todo: consolidate all of the scattered classes into this usa form one #} + {% with show_edit_button=True show_readonly=True group_classes="usa-form-readonly padding-top-2" %} {% input_with_errors form.full_name %} {% endwith %} -
-{% endblock profile_form %} \ No newline at end of file +{% endblock profile_form %} diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 6215d1fdc..045641ef9 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -523,6 +523,10 @@ class HomeTests(TestWithUser): class FinishUserProfileTests(TestWithUser, WebTest): """A series of tests that target the finish setup page for user profile""" + # csrf checks do not work well with WebTest. + # We disable them here. + csrf_checks = False + def setUp(self): super().setUp() self.user.title = None @@ -556,7 +560,8 @@ class FinishUserProfileTests(TestWithUser, WebTest): """Tests that a new user is redirected to the profile setup page when profile_feature is on""" self.app.set_user(self.incomplete_user.username) with override_flag("profile_feature", active=True): - # This will redirect the user to the setup page + # This will redirect the user to the setup page. + # Follow implicity checks if our redirect is working. finish_setup_page = self.app.get(reverse("home")).follow() self._set_session_cookie() @@ -578,10 +583,14 @@ class FinishUserProfileTests(TestWithUser, WebTest): finish_setup_form["phone"] = "(201) 555-0123" finish_setup_form["title"] = "CEO" finish_setup_form["last_name"] = "example" - completed_setup_page = self._submit_form_webtest(finish_setup_page.form, follow=True) + save_page = self._submit_form_webtest(finish_setup_form, follow=True) - self.assertEqual(completed_setup_page.status_code, 200) - # Assert that we're on the home page + self.assertEqual(save_page.status_code, 200) + self.assertContains(save_page, "Your profile has been updated.") + + # Try to navigate back to the home page. + # This is the same as clicking the back button. + completed_setup_page = self.app.get(reverse("home")) self.assertContains(completed_setup_page, "Manage your domain") @less_console_noise_decorator From 32135ef4b917bace506cef26bee80a1fbef0d7b1 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 21 May 2024 15:55:13 -0600 Subject: [PATCH 062/214] Cleanup js --- src/djangooidc/backends.py | 1 - src/djangooidc/views.py | 2 -- src/registrar/assets/js/get-gov.js | 6 +----- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py index 25179f7fd..41e442f2d 100644 --- a/src/djangooidc/backends.py +++ b/src/djangooidc/backends.py @@ -23,7 +23,6 @@ class OpenIdConnectBackend(ModelBackend): def authenticate(self, request, **kwargs): logger.debug("kwargs %s" % kwargs) user = None - if not kwargs or "sub" not in kwargs.keys(): return user diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py index bbf708d1c..815df4ecf 100644 --- a/src/djangooidc/views.py +++ b/src/djangooidc/views.py @@ -113,12 +113,10 @@ def login_callback(request): user.save() login(request, user) - logger.info("Successfully logged in user %s" % user) # Clear the flag if the exception is not caught request.session.pop("redirect_attempted", None) - return redirect(request.session.get("next", "/")) else: raise o_e.BannedUser() diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 2e536c973..2958399ec 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -920,11 +920,9 @@ function hideDeletedForms() { function showInputOnErrorFields(){ document.addEventListener('DOMContentLoaded', function() { - let form = document.querySelector("#finish-profile-setup-form"); - console.log(`form: ${form}`) // Get all input elements within the form + let form = document.querySelector("#finish-profile-setup-form"); let inputs = form ? form.querySelectorAll("input") : null; - console.log(`look: ${inputs}`) if (!inputs) { return null; } @@ -933,13 +931,11 @@ function hideDeletedForms() { inputs.forEach(function(input) { let fieldName = input.name; let errorMessage = document.querySelector(`#id_${fieldName}__error-message`); - console.log(`fieldName: ${inputs} vs err message ${errorMessage}`) if (!fieldName || !errorMessage) { return null; } let editButton = document.querySelector(`#${fieldName}__edit-button`); - console.log(`edit button is ${editButton} vs id #${fieldName}__edit-button`) if (editButton){ // Show the input field of the field that errored out editButton.click(); From 993095badab49847ea89d915b2ac3290fa434eb7 Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Wed, 22 May 2024 00:57:31 -0400 Subject: [PATCH 063/214] wip on delete and modals --- src/registrar/assets/js/get-gov.js | 442 +- src/registrar/assets/js/uswds-edited.js | 7051 +++++++++++++++++++++++ src/registrar/templates/base.html | 2 +- src/registrar/templates/home.html | 187 +- src/registrar/views/index.py | 29 +- 5 files changed, 7412 insertions(+), 299 deletions(-) create mode 100644 src/registrar/assets/js/uswds-edited.js diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 2679a2116..2141c2769 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -838,123 +838,133 @@ function hideDeletedForms() { document.addEventListener('DOMContentLoaded', function() { let currentPage = 1; - let currentSortBy = 'id'; - let currentOrder = 'asc'; + let currentSortBy = 'id'; + let currentOrder = 'asc'; + let domainsWrapper = document.querySelector('.domains-wrapper'); + let noDomainsWrapper = document.querySelector('.no-domains-wrapper'); function loadPage(page, sortBy = currentSortBy, order = currentOrder) { - fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}`) - .then(response => response.json()) - .then(data => { - if (data.error) { - alert(data.error); - return; - } + fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}`) + .then(response => response.json()) + .then(data => { + if (data.error) { + alert(data.error); + return; + } - const domainList = document.querySelector('.dotgov-table__registered-domains tbody'); - domainList.innerHTML = ''; + if (data.domains.length) { + domainsWrapper.classList.remove('display-none'); + noDomainsWrapper.classList.add('display-none'); + } else { + domainsWrapper.classList.add('display-none'); + noDomainsWrapper.classList.remove('display-none'); + } - data.domains.forEach(domain => { - const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; - const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; - - const row = document.createElement('tr'); - row.innerHTML = ` - - ${domain.name} - - - ${expirationDate ? expirationDate.toLocaleDateString() : ''} - - - ${domain.state_display} - - - - - - - - ${domain.state === 'deleted' || domain.state === 'on hold' ? 'View' : 'Manage'} ${domain.name} - - - `; - domainList.appendChild(row); - }); + const domainList = document.querySelector('.dotgov-table__registered-domains tbody'); + domainList.innerHTML = ''; - updatePagination(data.page, data.num_pages, data.has_previous, data.has_next); - currentPage = page; - currentSortBy = sortBy; - currentOrder = order; - }) - .catch(error => console.error('Error fetching domains:', error)); + data.domains.forEach(domain => { + const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; + const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; + + const row = document.createElement('tr'); + row.innerHTML = ` + + ${domain.name} + + + ${expirationDate ? expirationDate.toLocaleDateString() : ''} + + + ${domain.state_display} + + + + + + + + ${domain.state === 'deleted' || domain.state === 'on hold' ? 'View' : 'Manage'} ${domain.name} + + + `; + domainList.appendChild(row); + }); + + updatePagination(data.page, data.num_pages, data.has_previous, data.has_next); + currentPage = page; + currentSortBy = sortBy; + currentOrder = order; + }) + .catch(error => console.error('Error fetching domains:', error)); } function updatePagination(currentPage, numPages, hasPrevious, hasNext) { - const paginationContainer = document.querySelector('#domains-pagination .usa-pagination__list'); - paginationContainer.innerHTML = ''; + const paginationContainer = document.querySelector('#domains-pagination .usa-pagination__list'); + paginationContainer.innerHTML = ''; - if (hasPrevious) { - const prevPageItem = document.createElement('li'); - prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - prevPageItem.innerHTML = ` - - - Previous - - `; - prevPageItem.querySelector('a').addEventListener('click', () => loadPage(currentPage - 1)); - paginationContainer.appendChild(prevPageItem); - } + if (hasPrevious) { + const prevPageItem = document.createElement('li'); + prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; + prevPageItem.innerHTML = ` + + + Previous + + `; + prevPageItem.querySelector('a').addEventListener('click', () => loadPage(currentPage - 1)); + paginationContainer.appendChild(prevPageItem); + } - for (let i = 1; i <= numPages; i++) { - const pageItem = document.createElement('li'); - pageItem.className = 'usa-pagination__item usa-pagination__page-no'; - pageItem.innerHTML = ` - ${i} - `; - if (i === currentPage) { - pageItem.querySelector('a').classList.add('usa-current'); - pageItem.querySelector('a').setAttribute('aria-current', 'page'); - } - pageItem.querySelector('a').addEventListener('click', () => loadPage(i)); - paginationContainer.appendChild(pageItem); + for (let i = 1; i <= numPages; i++) { + const pageItem = document.createElement('li'); + pageItem.className = 'usa-pagination__item usa-pagination__page-no'; + pageItem.innerHTML = ` + ${i} + `; + if (i === currentPage) { + pageItem.querySelector('a').classList.add('usa-current'); + pageItem.querySelector('a').setAttribute('aria-current', 'page'); } + pageItem.querySelector('a').addEventListener('click', () => loadPage(i)); + paginationContainer.appendChild(pageItem); + } - if (hasNext) { - const nextPageItem = document.createElement('li'); - nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - nextPageItem.innerHTML = ` - - Next - - - `; - nextPageItem.querySelector('a').addEventListener('click', () => loadPage(currentPage + 1)); - paginationContainer.appendChild(nextPageItem); - } + if (hasNext) { + const nextPageItem = document.createElement('li'); + nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; + nextPageItem.innerHTML = ` + + Next + + + `; + nextPageItem.querySelector('a').addEventListener('click', () => loadPage(currentPage + 1)); + paginationContainer.appendChild(nextPageItem); + } } // Add event listeners to table headers for sorting document.querySelectorAll('.dotgov-table th[data-sortable]').forEach(header => { header.addEventListener('click', function() { - const sortBy = this.getAttribute('data-sortable'); - const order = currentOrder === 'asc' ? 'desc' : 'asc'; - loadPage(1, sortBy, order); + const sortBy = this.getAttribute('data-sortable'); + const order = currentOrder === 'asc' ? 'desc' : 'asc'; + loadPage(1, sortBy, order); }); -}); + }); // Load the first page initially loadPage(1); @@ -964,129 +974,151 @@ document.addEventListener('DOMContentLoaded', function() { let currentPage = 1; let currentSortBy = 'id'; let currentOrder = 'asc'; + let domainRequestsWrapper = document.querySelector('.domain-requests-wrapper'); + let noDomainRequestsWrapper = document.querySelector('.no-domain-requests-wrapper'); function loadDomainRequestsPage(page, sortBy = currentSortBy, order = currentOrder) { - fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}`) - .then(response => response.json()) - .then(data => { - if (data.error) { - alert(data.error); - return; - } + fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}`) + .then(response => response.json()) + .then(data => { + if (data.error) { + alert(data.error); + return; + } - const tbody = document.querySelector('.dotgov-table__domain-requests tbody'); - tbody.innerHTML = ''; + if (data.domain_requests.length) { + domainRequestsWrapper.classList.remove('display-none'); + noDomainRequestsWrapper.classList.add('display-none'); + } else { + domainRequestsWrapper.classList.add('display-none'); + noDomainRequestsWrapper.classList.remove('display-none'); + } - data.domain_requests.forEach(request => { - const domainName = request.requested_domain ? request.requested_domain.name : `New domain request (${new Date(request.created_at).toLocaleString()} UTC)`; - const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString() : 'Not submitted'; - const actionUrl = (request.status === 'Started' || request.status === 'Withdrawn') ? `/edit-domain-request/${request.id}` : `/domain-request-status/${request.id}`; - const actionLabel = (request.status === 'Started' || request.status === 'Withdrawn') ? 'Edit' : 'Manage'; - const deleteButton = request.is_deletable ? ` - - Delete - ` : ''; + const tbody = document.querySelector('.dotgov-table__domain-requests tbody'); + tbody.innerHTML = ''; - const row = document.createElement('tr'); - row.innerHTML = ` - - ${domainName} - - - ${submissionDate} - - - ${request.state_display} - - - - - - - - ${actionLabel} ${request.requested_domain ? request.requested_domain.name : 'New domain request'} - - - ${deleteButton} - `; - tbody.appendChild(row); - }); + data.domain_requests.forEach(request => { + const domainName = request.requested_domain ? request.requested_domain.name : `New domain request (${new Date(request.created_at).toLocaleString()} UTC)`; + const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString() : 'Not submitted'; + const actionUrl = (request.status === 'Started' || request.status === 'Withdrawn') ? `/domain-request/${request.id}/edit` : `/domain-request/${request.id}`; + const actionLabel = (request.status === 'Started' || request.status === 'Withdrawn') ? 'Edit' : 'Manage'; + const deleteButton = request.is_deletable ? ` + + Delete ${domainName} + ` : ''; - updateDomainRequestsPagination(data.page, data.num_pages, data.has_previous, data.has_next); - currentPage = page; - currentSortBy = sortBy; - currentOrder = order; - }) - .catch(error => console.error('Error fetching domain requests:', error)); + const row = document.createElement('tr'); + row.innerHTML = ` + + ${domainName} + + + ${submissionDate} + + + ${request.status} + + + + + + + + ${actionLabel} ${request.requested_domain ? request.requested_domain.name : 'New domain request'} + + + ${deleteButton} + `; + tbody.appendChild(row); + }); + + updateDomainRequestsPagination(data.page, data.num_pages, data.has_previous, data.has_next); + currentPage = page; + currentSortBy = sortBy; + currentOrder = order; + + // Add the testerPixel element to the DOM + const testerPixel = document.createElement('span'); + testerPixel.classList.add('display-none', 'testerPixel'); + document.body.appendChild(testerPixel); + }) + .catch(error => console.error('Error fetching domain requests:', error)); } function updateDomainRequestsPagination(currentPage, numPages, hasPrevious, hasNext) { - const paginationContainer = document.querySelector('#domain-requests-pagination .usa-pagination__list'); - paginationContainer.innerHTML = ''; + const paginationContainer = document.querySelector('#domain-requests-pagination .usa-pagination__list'); + paginationContainer.innerHTML = ''; - if (hasPrevious) { - const prevPageItem = document.createElement('li'); - prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - prevPageItem.innerHTML = ` - - - Previous - - `; - prevPageItem.querySelector('a').addEventListener('click', () => loadDomainRequestsPage(currentPage - 1)); - paginationContainer.appendChild(prevPageItem); - } + if (hasPrevious) { + const prevPageItem = document.createElement('li'); + prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; + prevPageItem.innerHTML = ` + + + Previous + + `; + prevPageItem.querySelector('a').addEventListener('click', () => loadDomainRequestsPage(currentPage - 1)); + paginationContainer.appendChild(prevPageItem); + } - for (let i = 1; i <= numPages; i++) { - const pageItem = document.createElement('li'); - pageItem.className = 'usa-pagination__item usa-pagination__page-no'; - pageItem.innerHTML = ` - ${i} - `; - if (i === currentPage) { - pageItem.querySelector('a').classList.add('usa-current'); - pageItem.querySelector('a').setAttribute('aria-current', 'page'); - } - pageItem.querySelector('a').addEventListener('click', () => loadDomainRequestsPage(i)); - paginationContainer.appendChild(pageItem); + for (let i = 1; i <= numPages; i++) { + const pageItem = document.createElement('li'); + pageItem.className = 'usa-pagination__item usa-pagination__page-no'; + pageItem.innerHTML = ` + ${i} + `; + if (i === currentPage) { + pageItem.querySelector('a').classList.add('usa-current'); + pageItem.querySelector('a').setAttribute('aria-current', 'page'); } + pageItem.querySelector('a').addEventListener('click', () => loadDomainRequestsPage(i)); + paginationContainer.appendChild(pageItem); + } - if (hasNext) { - const nextPageItem = document.createElement('li'); - nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - nextPageItem.innerHTML = ` - - Next - - - `; - nextPageItem.querySelector('a').addEventListener('click', () => loadDomainRequestsPage(currentPage + 1)); - paginationContainer.appendChild(nextPageItem); - } + if (hasNext) { + const nextPageItem = document.createElement('li'); + nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; + nextPageItem.innerHTML = ` + + Next + + + `; + nextPageItem.querySelector('a').addEventListener('click', () => loadDomainRequestsPage(currentPage + 1)); + paginationContainer.appendChild(nextPageItem); + } } // Add event listeners to table headers for sorting document.querySelectorAll('.dotgov-table__domain-requests th[data-sortable]').forEach(header => { - header.addEventListener('click', function() { - const sortBy = this.getAttribute('data-sortable'); - const order = currentOrder === 'asc' ? 'desc' : 'asc'; - loadDomainRequestsPage(1, sortBy, order); - }); + header.addEventListener('click', function() { + const sortBy = this.getAttribute('data-sortable'); + const order = currentOrder === 'asc' ? 'desc' : 'asc'; + loadDomainRequestsPage(1, sortBy, order); + }); }); // Load the first page initially diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js new file mode 100644 index 000000000..60f286bb5 --- /dev/null +++ b/src/registrar/assets/js/uswds-edited.js @@ -0,0 +1,7051 @@ +(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i'], + 191: ['/', '?'], + 192: ['`', '~'], + 219: ['[', '{'], + 220: ['\\', '|'], + 221: [']', '}'], + 222: ["'", '"'], + 224: 'Meta', + 225: 'AltGraph', + 246: 'Attn', + 247: 'CrSel', + 248: 'ExSel', + 249: 'EraseEof', + 250: 'Play', + 251: 'ZoomOut' + } + }; + + // Function keys (F1-24). + var i; + for (i = 1; i < 25; i++) { + keyboardeventKeyPolyfill.keys[111 + i] = 'F' + i; + } + + // Printable ASCII characters. + var letter = ''; + for (i = 65; i < 91; i++) { + letter = String.fromCharCode(i); + keyboardeventKeyPolyfill.keys[i] = [letter.toLowerCase(), letter.toUpperCase()]; + } + function polyfill() { + if (!('KeyboardEvent' in window) || 'key' in KeyboardEvent.prototype) { + return false; + } + + // Polyfill `key` on `KeyboardEvent`. + var proto = { + get: function (x) { + var key = keyboardeventKeyPolyfill.keys[this.which || this.keyCode]; + if (Array.isArray(key)) { + key = key[+this.shiftKey]; + } + return key; + } + }; + Object.defineProperty(KeyboardEvent.prototype, 'key', proto); + return proto; + } + if (typeof define === 'function' && define.amd) { + define('keyboardevent-key-polyfill', keyboardeventKeyPolyfill); + } else if (typeof exports !== 'undefined' && typeof module !== 'undefined') { + module.exports = keyboardeventKeyPolyfill; + } else if (window) { + window.keyboardeventKeyPolyfill = keyboardeventKeyPolyfill; + } +})(); + +},{}],4:[function(require,module,exports){ +/* +object-assign +(c) Sindre Sorhus +@license MIT +*/ + +'use strict'; + +/* eslint-disable no-unused-vars */ +var getOwnPropertySymbols = Object.getOwnPropertySymbols; +var hasOwnProperty = Object.prototype.hasOwnProperty; +var propIsEnumerable = Object.prototype.propertyIsEnumerable; +function toObject(val) { + if (val === null || val === undefined) { + throw new TypeError('Object.assign cannot be called with null or undefined'); + } + return Object(val); +} +function shouldUseNative() { + try { + if (!Object.assign) { + return false; + } + + // Detect buggy property enumeration order in older V8 versions. + + // https://bugs.chromium.org/p/v8/issues/detail?id=4118 + var test1 = new String('abc'); // eslint-disable-line no-new-wrappers + test1[5] = 'de'; + if (Object.getOwnPropertyNames(test1)[0] === '5') { + return false; + } + + // https://bugs.chromium.org/p/v8/issues/detail?id=3056 + var test2 = {}; + for (var i = 0; i < 10; i++) { + test2['_' + String.fromCharCode(i)] = i; + } + var order2 = Object.getOwnPropertyNames(test2).map(function (n) { + return test2[n]; + }); + if (order2.join('') !== '0123456789') { + return false; + } + + // https://bugs.chromium.org/p/v8/issues/detail?id=3056 + var test3 = {}; + 'abcdefghijklmnopqrst'.split('').forEach(function (letter) { + test3[letter] = letter; + }); + if (Object.keys(Object.assign({}, test3)).join('') !== 'abcdefghijklmnopqrst') { + return false; + } + return true; + } catch (err) { + // We don't expect any of the above to throw, but better to be safe. + return false; + } +} +module.exports = shouldUseNative() ? Object.assign : function (target, source) { + var from; + var to = toObject(target); + var symbols; + for (var s = 1; s < arguments.length; s++) { + from = Object(arguments[s]); + for (var key in from) { + if (hasOwnProperty.call(from, key)) { + to[key] = from[key]; + } + } + if (getOwnPropertySymbols) { + symbols = getOwnPropertySymbols(from); + for (var i = 0; i < symbols.length; i++) { + if (propIsEnumerable.call(from, symbols[i])) { + to[symbols[i]] = from[symbols[i]]; + } + } + } + } + return to; +}; + +},{}],5:[function(require,module,exports){ +"use strict"; + +const assign = require('object-assign'); +const delegate = require('../delegate'); +const delegateAll = require('../delegateAll'); +const DELEGATE_PATTERN = /^(.+):delegate\((.+)\)$/; +const SPACE = ' '; +const getListeners = function (type, handler) { + var match = type.match(DELEGATE_PATTERN); + var selector; + if (match) { + type = match[1]; + selector = match[2]; + } + var options; + if (typeof handler === 'object') { + options = { + capture: popKey(handler, 'capture'), + passive: popKey(handler, 'passive') + }; + } + var listener = { + selector: selector, + delegate: typeof handler === 'object' ? delegateAll(handler) : selector ? delegate(selector, handler) : handler, + options: options + }; + if (type.indexOf(SPACE) > -1) { + return type.split(SPACE).map(function (_type) { + return assign({ + type: _type + }, listener); + }); + } else { + listener.type = type; + return [listener]; + } +}; +var popKey = function (obj, key) { + var value = obj[key]; + delete obj[key]; + return value; +}; +module.exports = function behavior(events, props) { + const listeners = Object.keys(events).reduce(function (memo, type) { + var listeners = getListeners(type, events[type]); + return memo.concat(listeners); + }, []); + return assign({ + add: function addBehavior(element) { + listeners.forEach(function (listener) { + element.addEventListener(listener.type, listener.delegate, listener.options); + }); + }, + remove: function removeBehavior(element) { + listeners.forEach(function (listener) { + element.removeEventListener(listener.type, listener.delegate, listener.options); + }); + } + }, props); +}; + +},{"../delegate":7,"../delegateAll":8,"object-assign":4}],6:[function(require,module,exports){ +"use strict"; + +module.exports = function compose(functions) { + return function (e) { + return functions.some(function (fn) { + return fn.call(this, e) === false; + }, this); + }; +}; + +},{}],7:[function(require,module,exports){ +"use strict"; + +// polyfill Element.prototype.closest +require('element-closest'); +module.exports = function delegate(selector, fn) { + return function delegation(event) { + var target = event.target.closest(selector); + if (target) { + return fn.call(target, event); + } + }; +}; + +},{"element-closest":2}],8:[function(require,module,exports){ +"use strict"; + +const delegate = require('../delegate'); +const compose = require('../compose'); +const SPLAT = '*'; +module.exports = function delegateAll(selectors) { + const keys = Object.keys(selectors); + + // XXX optimization: if there is only one handler and it applies to + // all elements (the "*" CSS selector), then just return that + // handler + if (keys.length === 1 && keys[0] === SPLAT) { + return selectors[SPLAT]; + } + const delegates = keys.reduce(function (memo, selector) { + memo.push(delegate(selector, selectors[selector])); + return memo; + }, []); + return compose(delegates); +}; + +},{"../compose":6,"../delegate":7}],9:[function(require,module,exports){ +"use strict"; + +module.exports = function ignore(element, fn) { + return function ignorance(e) { + if (element !== e.target && !element.contains(e.target)) { + return fn.call(this, e); + } + }; +}; + +},{}],10:[function(require,module,exports){ +"use strict"; + +module.exports = { + behavior: require('./behavior'), + delegate: require('./delegate'), + delegateAll: require('./delegateAll'), + ignore: require('./ignore'), + keymap: require('./keymap') +}; + +},{"./behavior":5,"./delegate":7,"./delegateAll":8,"./ignore":9,"./keymap":11}],11:[function(require,module,exports){ +"use strict"; + +require('keyboardevent-key-polyfill'); + +// these are the only relevant modifiers supported on all platforms, +// according to MDN: +// +const MODIFIERS = { + 'Alt': 'altKey', + 'Control': 'ctrlKey', + 'Ctrl': 'ctrlKey', + 'Shift': 'shiftKey' +}; +const MODIFIER_SEPARATOR = '+'; +const getEventKey = function (event, hasModifiers) { + var key = event.key; + if (hasModifiers) { + for (var modifier in MODIFIERS) { + if (event[MODIFIERS[modifier]] === true) { + key = [modifier, key].join(MODIFIER_SEPARATOR); + } + } + } + return key; +}; +module.exports = function keymap(keys) { + const hasModifiers = Object.keys(keys).some(function (key) { + return key.indexOf(MODIFIER_SEPARATOR) > -1; + }); + return function (event) { + var key = getEventKey(event, hasModifiers); + return [key, key.toLowerCase()].reduce(function (result, _key) { + if (_key in keys) { + result = keys[key].call(this, event); + } + return result; + }, undefined); + }; +}; +module.exports.MODIFIERS = MODIFIERS; + +},{"keyboardevent-key-polyfill":3}],12:[function(require,module,exports){ +"use strict"; + +module.exports = function once(listener, options) { + var wrapped = function wrappedOnce(e) { + e.currentTarget.removeEventListener(e.type, wrapped, options); + return listener.call(this, e); + }; + return wrapped; +}; + +},{}],13:[function(require,module,exports){ +'use strict'; + +var RE_TRIM = /(^\s+)|(\s+$)/g; +var RE_SPLIT = /\s+/; +var trim = String.prototype.trim ? function (str) { + return str.trim(); +} : function (str) { + return str.replace(RE_TRIM, ''); +}; +var queryById = function (id) { + return this.querySelector('[id="' + id.replace(/"/g, '\\"') + '"]'); +}; +module.exports = function resolveIds(ids, doc) { + if (typeof ids !== 'string') { + throw new Error('Expected a string but got ' + typeof ids); + } + if (!doc) { + doc = window.document; + } + var getElementById = doc.getElementById ? doc.getElementById.bind(doc) : queryById.bind(doc); + ids = trim(ids).split(RE_SPLIT); + + // XXX we can short-circuit here because trimming and splitting a + // string of just whitespace produces an array containing a single, + // empty string + if (ids.length === 1 && ids[0] === '') { + return []; + } + return ids.map(function (id) { + var el = getElementById(id); + if (!el) { + throw new Error('no element with id: "' + id + '"'); + } + return el; + }); +}; + +},{}],14:[function(require,module,exports){ +"use strict"; + +const behavior = require("../../uswds-core/src/js/utils/behavior"); +const toggleFormInput = require("../../uswds-core/src/js/utils/toggle-form-input"); +const { + CLICK +} = require("../../uswds-core/src/js/events"); +const { + prefix: PREFIX +} = require("../../uswds-core/src/js/config"); +const LINK = `.${PREFIX}-show-password`; +function toggle(event) { + event.preventDefault(); + toggleFormInput(this); +} +module.exports = behavior({ + [CLICK]: { + [LINK]: toggle + } +}); + +},{"../../uswds-core/src/js/config":35,"../../uswds-core/src/js/events":36,"../../uswds-core/src/js/utils/behavior":45,"../../uswds-core/src/js/utils/toggle-form-input":55}],15:[function(require,module,exports){ +"use strict"; + +const select = require("../../uswds-core/src/js/utils/select"); +const behavior = require("../../uswds-core/src/js/utils/behavior"); +const toggle = require("../../uswds-core/src/js/utils/toggle"); +const isElementInViewport = require("../../uswds-core/src/js/utils/is-in-viewport"); +const { + CLICK +} = require("../../uswds-core/src/js/events"); +const { + prefix: PREFIX +} = require("../../uswds-core/src/js/config"); +const ACCORDION = `.${PREFIX}-accordion, .${PREFIX}-accordion--bordered`; +const BUTTON = `.${PREFIX}-accordion__button[aria-controls]`; +const EXPANDED = "aria-expanded"; +const MULTISELECTABLE = "data-allow-multiple"; + +/** + * Get an Array of button elements belonging directly to the given + * accordion element. + * @param {HTMLElement} accordion + * @return {array} + */ +const getAccordionButtons = accordion => { + const buttons = select(BUTTON, accordion); + return buttons.filter(button => button.closest(ACCORDION) === accordion); +}; + +/** + * Toggle a button's "pressed" state, optionally providing a target + * state. + * + * @param {HTMLButtonElement} button + * @param {boolean?} expanded If no state is provided, the current + * state will be toggled (from false to true, and vice-versa). + * @return {boolean} the resulting state + */ +const toggleButton = (button, expanded) => { + const accordion = button.closest(ACCORDION); + let safeExpanded = expanded; + if (!accordion) { + throw new Error(`${BUTTON} is missing outer ${ACCORDION}`); + } + safeExpanded = toggle(button, expanded); + + // XXX multiselectable is opt-in, to preserve legacy behavior + const multiselectable = accordion.hasAttribute(MULTISELECTABLE); + if (safeExpanded && !multiselectable) { + getAccordionButtons(accordion).forEach(other => { + if (other !== button) { + toggle(other, false); + } + }); + } +}; + +/** + * @param {HTMLButtonElement} button + * @return {boolean} true + */ +const showButton = button => toggleButton(button, true); + +/** + * @param {HTMLButtonElement} button + * @return {boolean} false + */ +const hideButton = button => toggleButton(button, false); +const accordion = behavior({ + [CLICK]: { + [BUTTON](event) { + toggleButton(this); + if (this.getAttribute(EXPANDED) === "true") { + // We were just expanded, but if another accordion was also just + // collapsed, we may no longer be in the viewport. This ensures + // that we are still visible, so the user isn't confused. + if (!isElementInViewport(this)) this.scrollIntoView(); + } + } + } +}, { + init(root) { + select(BUTTON, root).forEach(button => { + const expanded = button.getAttribute(EXPANDED) === "true"; + toggleButton(button, expanded); + }); + }, + ACCORDION, + BUTTON, + show: showButton, + hide: hideButton, + toggle: toggleButton, + getButtons: getAccordionButtons +}); +module.exports = accordion; + +},{"../../uswds-core/src/js/config":35,"../../uswds-core/src/js/events":36,"../../uswds-core/src/js/utils/behavior":45,"../../uswds-core/src/js/utils/is-in-viewport":48,"../../uswds-core/src/js/utils/select":53,"../../uswds-core/src/js/utils/toggle":56}],16:[function(require,module,exports){ +"use strict"; + +const behavior = require("../../uswds-core/src/js/utils/behavior"); +const { + CLICK +} = require("../../uswds-core/src/js/events"); +const { + prefix: PREFIX +} = require("../../uswds-core/src/js/config"); +const HEADER = `.${PREFIX}-banner__header`; +const EXPANDED_CLASS = `${PREFIX}-banner__header--expanded`; +const toggleBanner = function toggleEl(event) { + event.preventDefault(); + this.closest(HEADER).classList.toggle(EXPANDED_CLASS); +}; +module.exports = behavior({ + [CLICK]: { + [`${HEADER} [aria-controls]`]: toggleBanner + } +}); + +},{"../../uswds-core/src/js/config":35,"../../uswds-core/src/js/events":36,"../../uswds-core/src/js/utils/behavior":45}],17:[function(require,module,exports){ +"use strict"; + +const select = require("../../uswds-core/src/js/utils/select"); +const behavior = require("../../uswds-core/src/js/utils/behavior"); +const debounce = require("../../uswds-core/src/js/utils/debounce"); +const { + prefix: PREFIX +} = require("../../uswds-core/src/js/config"); +const CHARACTER_COUNT_CLASS = `${PREFIX}-character-count`; +const CHARACTER_COUNT = `.${CHARACTER_COUNT_CLASS}`; +const INPUT = `.${PREFIX}-character-count__field`; +const MESSAGE = `.${PREFIX}-character-count__message`; +const VALIDATION_MESSAGE = "The content is too long."; +const MESSAGE_INVALID_CLASS = `${PREFIX}-character-count__status--invalid`; +const STATUS_MESSAGE_CLASS = `${CHARACTER_COUNT_CLASS}__status`; +const STATUS_MESSAGE_SR_ONLY_CLASS = `${CHARACTER_COUNT_CLASS}__sr-status`; +const STATUS_MESSAGE = `.${STATUS_MESSAGE_CLASS}`; +const STATUS_MESSAGE_SR_ONLY = `.${STATUS_MESSAGE_SR_ONLY_CLASS}`; +const DEFAULT_STATUS_LABEL = `characters allowed`; + +/** + * Returns the root and message element for an character count input + * + * @param {HTMLInputElement|HTMLTextAreaElement} inputEl The character count input element + * @returns {CharacterCountElements} elements The root and message element. + */ +const getCharacterCountElements = inputEl => { + const characterCountEl = inputEl.closest(CHARACTER_COUNT); + if (!characterCountEl) { + throw new Error(`${INPUT} is missing outer ${CHARACTER_COUNT}`); + } + const messageEl = characterCountEl.querySelector(MESSAGE); + if (!messageEl) { + throw new Error(`${CHARACTER_COUNT} is missing inner ${MESSAGE}`); + } + return { + characterCountEl, + messageEl + }; +}; + +/** + * Move maxlength attribute to a data attribute on usa-character-count + * + * @param {HTMLInputElement|HTMLTextAreaElement} inputEl The character count input element + */ +const setDataLength = inputEl => { + const { + characterCountEl + } = getCharacterCountElements(inputEl); + const maxlength = inputEl.getAttribute("maxlength"); + if (!maxlength) return; + inputEl.removeAttribute("maxlength"); + characterCountEl.setAttribute("data-maxlength", maxlength); +}; + +/** + * Create and append status messages for visual and screen readers + * + * @param {HTMLDivElement} characterCountEl - Div with `.usa-character-count` class + * @description Create two status messages for number of characters left; + * one visual status and another for screen readers + */ +const createStatusMessages = characterCountEl => { + const statusMessage = document.createElement("div"); + const srStatusMessage = document.createElement("div"); + const maxLength = characterCountEl.dataset.maxlength; + const defaultMessage = `${maxLength} ${DEFAULT_STATUS_LABEL}`; + statusMessage.classList.add(`${STATUS_MESSAGE_CLASS}`, "usa-hint"); + srStatusMessage.classList.add(`${STATUS_MESSAGE_SR_ONLY_CLASS}`, "usa-sr-only"); + statusMessage.setAttribute("aria-hidden", true); + srStatusMessage.setAttribute("aria-live", "polite"); + statusMessage.textContent = defaultMessage; + srStatusMessage.textContent = defaultMessage; + characterCountEl.append(statusMessage, srStatusMessage); +}; + +/** + * Returns message with how many characters are left + * + * @param {number} currentLength - The number of characters used + * @param {number} maxLength - The total number of characters allowed + * @returns {string} A string description of how many characters are left + */ +const getCountMessage = (currentLength, maxLength) => { + let newMessage = ""; + if (currentLength === 0) { + newMessage = `${maxLength} ${DEFAULT_STATUS_LABEL}`; + } else { + const difference = Math.abs(maxLength - currentLength); + const characters = `character${difference === 1 ? "" : "s"}`; + const guidance = currentLength > maxLength ? "over limit" : "left"; + newMessage = `${difference} ${characters} ${guidance}`; + } + return newMessage; +}; + +/** + * Updates the character count status for screen readers after a 1000ms delay. + * + * @param {HTMLElement} msgEl - The screen reader status message element + * @param {string} statusMessage - A string of the current character status + */ +const srUpdateStatus = debounce((msgEl, statusMessage) => { + const srStatusMessage = msgEl; + srStatusMessage.textContent = statusMessage; +}, 1000); + +/** + * Update the character count component + * + * @description On input, it will update visual status, screenreader + * status and update input validation (if over character length) + * @param {HTMLInputElement|HTMLTextAreaElement} inputEl The character count input element + */ +const updateCountMessage = inputEl => { + const { + characterCountEl + } = getCharacterCountElements(inputEl); + const currentLength = inputEl.value.length; + const maxLength = parseInt(characterCountEl.getAttribute("data-maxlength"), 10); + const statusMessage = characterCountEl.querySelector(STATUS_MESSAGE); + const srStatusMessage = characterCountEl.querySelector(STATUS_MESSAGE_SR_ONLY); + const currentStatusMessage = getCountMessage(currentLength, maxLength); + if (!maxLength) return; + const isOverLimit = currentLength && currentLength > maxLength; + statusMessage.textContent = currentStatusMessage; + srUpdateStatus(srStatusMessage, currentStatusMessage); + if (isOverLimit && !inputEl.validationMessage) { + inputEl.setCustomValidity(VALIDATION_MESSAGE); + } + if (!isOverLimit && inputEl.validationMessage === VALIDATION_MESSAGE) { + inputEl.setCustomValidity(""); + } + statusMessage.classList.toggle(MESSAGE_INVALID_CLASS, isOverLimit); +}; + +/** + * Initialize component + * + * @description On init this function will create elements and update any + * attributes so it can tell the user how many characters are left. + * @param {HTMLInputElement|HTMLTextAreaElement} inputEl the components input + */ +const enhanceCharacterCount = inputEl => { + const { + characterCountEl, + messageEl + } = getCharacterCountElements(inputEl); + + // Hide hint and remove aria-live for backwards compatibility + messageEl.classList.add("usa-sr-only"); + messageEl.removeAttribute("aria-live"); + setDataLength(inputEl); + createStatusMessages(characterCountEl); +}; +const characterCount = behavior({ + input: { + [INPUT]() { + updateCountMessage(this); + } + } +}, { + init(root) { + select(INPUT, root).forEach(input => enhanceCharacterCount(input)); + }, + MESSAGE_INVALID_CLASS, + VALIDATION_MESSAGE, + STATUS_MESSAGE_CLASS, + STATUS_MESSAGE_SR_ONLY_CLASS, + DEFAULT_STATUS_LABEL, + createStatusMessages, + getCountMessage, + updateCountMessage +}); +module.exports = characterCount; + +},{"../../uswds-core/src/js/config":35,"../../uswds-core/src/js/utils/behavior":45,"../../uswds-core/src/js/utils/debounce":46,"../../uswds-core/src/js/utils/select":53}],18:[function(require,module,exports){ +"use strict"; + +const keymap = require("receptor/keymap"); +const selectOrMatches = require("../../uswds-core/src/js/utils/select-or-matches"); +const behavior = require("../../uswds-core/src/js/utils/behavior"); +const Sanitizer = require("../../uswds-core/src/js/utils/sanitizer"); +const { + prefix: PREFIX +} = require("../../uswds-core/src/js/config"); +const { + CLICK +} = require("../../uswds-core/src/js/events"); +const COMBO_BOX_CLASS = `${PREFIX}-combo-box`; +const COMBO_BOX_PRISTINE_CLASS = `${COMBO_BOX_CLASS}--pristine`; +const SELECT_CLASS = `${COMBO_BOX_CLASS}__select`; +const INPUT_CLASS = `${COMBO_BOX_CLASS}__input`; +const CLEAR_INPUT_BUTTON_CLASS = `${COMBO_BOX_CLASS}__clear-input`; +const CLEAR_INPUT_BUTTON_WRAPPER_CLASS = `${CLEAR_INPUT_BUTTON_CLASS}__wrapper`; +const INPUT_BUTTON_SEPARATOR_CLASS = `${COMBO_BOX_CLASS}__input-button-separator`; +const TOGGLE_LIST_BUTTON_CLASS = `${COMBO_BOX_CLASS}__toggle-list`; +const TOGGLE_LIST_BUTTON_WRAPPER_CLASS = `${TOGGLE_LIST_BUTTON_CLASS}__wrapper`; +const LIST_CLASS = `${COMBO_BOX_CLASS}__list`; +const LIST_OPTION_CLASS = `${COMBO_BOX_CLASS}__list-option`; +const LIST_OPTION_FOCUSED_CLASS = `${LIST_OPTION_CLASS}--focused`; +const LIST_OPTION_SELECTED_CLASS = `${LIST_OPTION_CLASS}--selected`; +const STATUS_CLASS = `${COMBO_BOX_CLASS}__status`; +const COMBO_BOX = `.${COMBO_BOX_CLASS}`; +const SELECT = `.${SELECT_CLASS}`; +const INPUT = `.${INPUT_CLASS}`; +const CLEAR_INPUT_BUTTON = `.${CLEAR_INPUT_BUTTON_CLASS}`; +const TOGGLE_LIST_BUTTON = `.${TOGGLE_LIST_BUTTON_CLASS}`; +const LIST = `.${LIST_CLASS}`; +const LIST_OPTION = `.${LIST_OPTION_CLASS}`; +const LIST_OPTION_FOCUSED = `.${LIST_OPTION_FOCUSED_CLASS}`; +const LIST_OPTION_SELECTED = `.${LIST_OPTION_SELECTED_CLASS}`; +const STATUS = `.${STATUS_CLASS}`; +const DEFAULT_FILTER = ".*{{query}}.*"; +const noop = () => {}; + +/** + * set the value of the element and dispatch a change event + * + * @param {HTMLInputElement|HTMLSelectElement} el The element to update + * @param {string} value The new value of the element + */ +const changeElementValue = function (el) { + let value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ""; + const elementToChange = el; + elementToChange.value = value; + const event = new CustomEvent("change", { + bubbles: true, + cancelable: true, + detail: { + value + } + }); + elementToChange.dispatchEvent(event); +}; + +/** + * The elements within the combo box. + * @typedef {Object} ComboBoxContext + * @property {HTMLElement} comboBoxEl + * @property {HTMLSelectElement} selectEl + * @property {HTMLInputElement} inputEl + * @property {HTMLUListElement} listEl + * @property {HTMLDivElement} statusEl + * @property {HTMLLIElement} focusedOptionEl + * @property {HTMLLIElement} selectedOptionEl + * @property {HTMLButtonElement} toggleListBtnEl + * @property {HTMLButtonElement} clearInputBtnEl + * @property {boolean} isPristine + * @property {boolean} disableFiltering + */ + +/** + * Get an object of elements belonging directly to the given + * combo box component. + * + * @param {HTMLElement} el the element within the combo box + * @returns {ComboBoxContext} elements + */ +const getComboBoxContext = el => { + const comboBoxEl = el.closest(COMBO_BOX); + if (!comboBoxEl) { + throw new Error(`Element is missing outer ${COMBO_BOX}`); + } + const selectEl = comboBoxEl.querySelector(SELECT); + const inputEl = comboBoxEl.querySelector(INPUT); + const listEl = comboBoxEl.querySelector(LIST); + const statusEl = comboBoxEl.querySelector(STATUS); + const focusedOptionEl = comboBoxEl.querySelector(LIST_OPTION_FOCUSED); + const selectedOptionEl = comboBoxEl.querySelector(LIST_OPTION_SELECTED); + const toggleListBtnEl = comboBoxEl.querySelector(TOGGLE_LIST_BUTTON); + const clearInputBtnEl = comboBoxEl.querySelector(CLEAR_INPUT_BUTTON); + const isPristine = comboBoxEl.classList.contains(COMBO_BOX_PRISTINE_CLASS); + const disableFiltering = comboBoxEl.dataset.disableFiltering === "true"; + return { + comboBoxEl, + selectEl, + inputEl, + listEl, + statusEl, + focusedOptionEl, + selectedOptionEl, + toggleListBtnEl, + clearInputBtnEl, + isPristine, + disableFiltering + }; +}; + +/** + * Disable the combo-box component + * + * @param {HTMLInputElement} el An element within the combo box component + */ +const disable = el => { + const { + inputEl, + toggleListBtnEl, + clearInputBtnEl + } = getComboBoxContext(el); + clearInputBtnEl.hidden = true; + clearInputBtnEl.disabled = true; + toggleListBtnEl.disabled = true; + inputEl.disabled = true; +}; + +/** + * Enable the combo-box component + * + * @param {HTMLInputElement} el An element within the combo box component + */ +const enable = el => { + const { + inputEl, + toggleListBtnEl, + clearInputBtnEl + } = getComboBoxContext(el); + clearInputBtnEl.hidden = false; + clearInputBtnEl.disabled = false; + toggleListBtnEl.disabled = false; + inputEl.disabled = false; +}; + +/** + * Enhance a select element into a combo box component. + * + * @param {HTMLElement} _comboBoxEl The initial element of the combo box component + */ +const enhanceComboBox = _comboBoxEl => { + const comboBoxEl = _comboBoxEl.closest(COMBO_BOX); + if (comboBoxEl.dataset.enhanced) return; + const selectEl = comboBoxEl.querySelector("select"); + if (!selectEl) { + throw new Error(`${COMBO_BOX} is missing inner select`); + } + const selectId = selectEl.id; + const selectLabel = document.querySelector(`label[for="${selectId}"]`); + const listId = `${selectId}--list`; + const listIdLabel = `${selectId}-label`; + const assistiveHintID = `${selectId}--assistiveHint`; + const additionalAttributes = []; + const { + defaultValue + } = comboBoxEl.dataset; + const { + placeholder + } = comboBoxEl.dataset; + let selectedOption; + if (placeholder) { + additionalAttributes.push({ + placeholder + }); + } + if (defaultValue) { + for (let i = 0, len = selectEl.options.length; i < len; i += 1) { + const optionEl = selectEl.options[i]; + if (optionEl.value === defaultValue) { + selectedOption = optionEl; + break; + } + } + } + + /** + * Throw error if combobox is missing a label or label is missing + * `for` attribute. Otherwise, set the ID to match the
    aria-labelledby + */ + if (!selectLabel || !selectLabel.matches(`label[for="${selectId}"]`)) { + throw new Error(`${COMBO_BOX} for ${selectId} is either missing a label or a "for" attribute`); + } else { + selectLabel.setAttribute("id", listIdLabel); + } + selectLabel.setAttribute("id", listIdLabel); + selectEl.setAttribute("aria-hidden", "true"); + selectEl.setAttribute("tabindex", "-1"); + selectEl.classList.add("usa-sr-only", SELECT_CLASS); + selectEl.id = ""; + selectEl.value = ""; + ["required", "aria-label", "aria-labelledby"].forEach(name => { + if (selectEl.hasAttribute(name)) { + const value = selectEl.getAttribute(name); + additionalAttributes.push({ + [name]: value + }); + selectEl.removeAttribute(name); + } + }); + + // sanitize doesn't like functions in template literals + const input = document.createElement("input"); + input.setAttribute("id", selectId); + input.setAttribute("aria-owns", listId); + input.setAttribute("aria-controls", listId); + input.setAttribute("aria-autocomplete", "list"); + input.setAttribute("aria-describedby", assistiveHintID); + input.setAttribute("aria-expanded", "false"); + input.setAttribute("autocapitalize", "off"); + input.setAttribute("autocomplete", "off"); + input.setAttribute("class", INPUT_CLASS); + input.setAttribute("type", "text"); + input.setAttribute("role", "combobox"); + additionalAttributes.forEach(attr => Object.keys(attr).forEach(key => { + const value = Sanitizer.escapeHTML`${attr[key]}`; + input.setAttribute(key, value); + })); + comboBoxEl.insertAdjacentElement("beforeend", input); + comboBoxEl.insertAdjacentHTML("beforeend", Sanitizer.escapeHTML` + + + +   + + + + +
    + + When autocomplete results are available use up and down arrows to review and enter to select. + Touch device users, explore by touch or with swipe gestures. + `); + if (selectedOption) { + const { + inputEl + } = getComboBoxContext(comboBoxEl); + changeElementValue(selectEl, selectedOption.value); + changeElementValue(inputEl, selectedOption.text); + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); + } + if (selectEl.disabled) { + disable(comboBoxEl); + selectEl.disabled = false; + } + comboBoxEl.dataset.enhanced = "true"; +}; + +/** + * Manage the focused element within the list options when + * navigating via keyboard. + * + * @param {HTMLElement} el An anchor element within the combo box component + * @param {HTMLElement} nextEl An element within the combo box component + * @param {Object} options options + * @param {boolean} options.skipFocus skip focus of highlighted item + * @param {boolean} options.preventScroll should skip procedure to scroll to element + */ +const highlightOption = function (el, nextEl) { + let { + skipFocus, + preventScroll + } = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + const { + inputEl, + listEl, + focusedOptionEl + } = getComboBoxContext(el); + if (focusedOptionEl) { + focusedOptionEl.classList.remove(LIST_OPTION_FOCUSED_CLASS); + focusedOptionEl.setAttribute("tabIndex", "-1"); + } + if (nextEl) { + inputEl.setAttribute("aria-activedescendant", nextEl.id); + nextEl.setAttribute("tabIndex", "0"); + nextEl.classList.add(LIST_OPTION_FOCUSED_CLASS); + if (!preventScroll) { + const optionBottom = nextEl.offsetTop + nextEl.offsetHeight; + const currentBottom = listEl.scrollTop + listEl.offsetHeight; + if (optionBottom > currentBottom) { + listEl.scrollTop = optionBottom - listEl.offsetHeight; + } + if (nextEl.offsetTop < listEl.scrollTop) { + listEl.scrollTop = nextEl.offsetTop; + } + } + if (!skipFocus) { + nextEl.focus({ + preventScroll + }); + } + } else { + inputEl.setAttribute("aria-activedescendant", ""); + inputEl.focus(); + } +}; + +/** + * Generate a dynamic regular expression based off of a replaceable and possibly filtered value. + * + * @param {string} el An element within the combo box component + * @param {string} query The value to use in the regular expression + * @param {object} extras An object of regular expressions to replace and filter the query + */ +const generateDynamicRegExp = function (filter) { + let query = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ""; + let extras = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + const escapeRegExp = text => text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); + let find = filter.replace(/{{(.*?)}}/g, (m, $1) => { + const key = $1.trim(); + const queryFilter = extras[key]; + if (key !== "query" && queryFilter) { + const matcher = new RegExp(queryFilter, "i"); + const matches = query.match(matcher); + if (matches) { + return escapeRegExp(matches[1]); + } + return ""; + } + return escapeRegExp(query); + }); + find = `^(?:${find})$`; + return new RegExp(find, "i"); +}; + +/** + * Display the option list of a combo box component. + * + * @param {HTMLElement} el An element within the combo box component + */ +const displayList = el => { + const { + comboBoxEl, + selectEl, + inputEl, + listEl, + statusEl, + isPristine, + disableFiltering + } = getComboBoxContext(el); + let selectedItemId; + let firstFoundId; + const listOptionBaseId = `${listEl.id}--option-`; + const inputValue = (inputEl.value || "").toLowerCase(); + const filter = comboBoxEl.dataset.filter || DEFAULT_FILTER; + const regex = generateDynamicRegExp(filter, inputValue, comboBoxEl.dataset); + const options = []; + for (let i = 0, len = selectEl.options.length; i < len; i += 1) { + const optionEl = selectEl.options[i]; + const optionId = `${listOptionBaseId}${options.length}`; + if (optionEl.value && (disableFiltering || isPristine || !inputValue || regex.test(optionEl.text))) { + if (selectEl.value && optionEl.value === selectEl.value) { + selectedItemId = optionId; + } + if (disableFiltering && !firstFoundId && regex.test(optionEl.text)) { + firstFoundId = optionId; + } + options.push(optionEl); + } + } + const numOptions = options.length; + const optionHtml = options.map((option, index) => { + const optionId = `${listOptionBaseId}${index}`; + const classes = [LIST_OPTION_CLASS]; + let tabindex = "-1"; + let ariaSelected = "false"; + if (optionId === selectedItemId) { + classes.push(LIST_OPTION_SELECTED_CLASS, LIST_OPTION_FOCUSED_CLASS); + tabindex = "0"; + ariaSelected = "true"; + } + if (!selectedItemId && index === 0) { + classes.push(LIST_OPTION_FOCUSED_CLASS); + tabindex = "0"; + } + const li = document.createElement("li"); + li.setAttribute("aria-setsize", options.length); + li.setAttribute("aria-posinset", index + 1); + li.setAttribute("aria-selected", ariaSelected); + li.setAttribute("id", optionId); + li.setAttribute("class", classes.join(" ")); + li.setAttribute("tabindex", tabindex); + li.setAttribute("role", "option"); + li.setAttribute("data-value", option.value); + li.textContent = option.text; + return li; + }); + const noResults = document.createElement("li"); + noResults.setAttribute("class", `${LIST_OPTION_CLASS}--no-results`); + noResults.textContent = "No results found"; + listEl.hidden = false; + if (numOptions) { + listEl.innerHTML = ""; + optionHtml.forEach(item => listEl.insertAdjacentElement("beforeend", item)); + } else { + listEl.innerHTML = ""; + listEl.insertAdjacentElement("beforeend", noResults); + } + inputEl.setAttribute("aria-expanded", "true"); + statusEl.textContent = numOptions ? `${numOptions} result${numOptions > 1 ? "s" : ""} available.` : "No results."; + let itemToFocus; + if (isPristine && selectedItemId) { + itemToFocus = listEl.querySelector(`#${selectedItemId}`); + } else if (disableFiltering && firstFoundId) { + itemToFocus = listEl.querySelector(`#${firstFoundId}`); + } + if (itemToFocus) { + highlightOption(listEl, itemToFocus, { + skipFocus: true + }); + } +}; + +/** + * Hide the option list of a combo box component. + * + * @param {HTMLElement} el An element within the combo box component + */ +const hideList = el => { + const { + inputEl, + listEl, + statusEl, + focusedOptionEl + } = getComboBoxContext(el); + statusEl.innerHTML = ""; + inputEl.setAttribute("aria-expanded", "false"); + inputEl.setAttribute("aria-activedescendant", ""); + if (focusedOptionEl) { + focusedOptionEl.classList.remove(LIST_OPTION_FOCUSED_CLASS); + } + listEl.scrollTop = 0; + listEl.hidden = true; +}; + +/** + * Select an option list of the combo box component. + * + * @param {HTMLElement} listOptionEl The list option being selected + */ +const selectItem = listOptionEl => { + const { + comboBoxEl, + selectEl, + inputEl + } = getComboBoxContext(listOptionEl); + changeElementValue(selectEl, listOptionEl.dataset.value); + changeElementValue(inputEl, listOptionEl.textContent); + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); + hideList(comboBoxEl); + inputEl.focus(); +}; + +/** + * Clear the input of the combo box + * + * @param {HTMLButtonElement} clearButtonEl The clear input button + */ +const clearInput = clearButtonEl => { + const { + comboBoxEl, + listEl, + selectEl, + inputEl + } = getComboBoxContext(clearButtonEl); + const listShown = !listEl.hidden; + if (selectEl.value) changeElementValue(selectEl); + if (inputEl.value) changeElementValue(inputEl); + comboBoxEl.classList.remove(COMBO_BOX_PRISTINE_CLASS); + if (listShown) displayList(comboBoxEl); + inputEl.focus(); +}; + +/** + * Reset the select based off of currently set select value + * + * @param {HTMLElement} el An element within the combo box component + */ +const resetSelection = el => { + const { + comboBoxEl, + selectEl, + inputEl + } = getComboBoxContext(el); + const selectValue = selectEl.value; + const inputValue = (inputEl.value || "").toLowerCase(); + if (selectValue) { + for (let i = 0, len = selectEl.options.length; i < len; i += 1) { + const optionEl = selectEl.options[i]; + if (optionEl.value === selectValue) { + if (inputValue !== optionEl.text) { + changeElementValue(inputEl, optionEl.text); + } + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); + return; + } + } + } + if (inputValue) { + changeElementValue(inputEl); + } +}; + +/** + * Select an option list of the combo box component based off of + * having a current focused list option or + * having test that completely matches a list option. + * Otherwise it clears the input and select. + * + * @param {HTMLElement} el An element within the combo box component + */ +const completeSelection = el => { + const { + comboBoxEl, + selectEl, + inputEl, + statusEl + } = getComboBoxContext(el); + statusEl.textContent = ""; + const inputValue = (inputEl.value || "").toLowerCase(); + if (inputValue) { + for (let i = 0, len = selectEl.options.length; i < len; i += 1) { + const optionEl = selectEl.options[i]; + if (optionEl.text.toLowerCase() === inputValue) { + changeElementValue(selectEl, optionEl.value); + changeElementValue(inputEl, optionEl.text); + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); + return; + } + } + } + resetSelection(comboBoxEl); +}; + +/** + * Handle the escape event within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ +const handleEscape = event => { + const { + comboBoxEl, + inputEl + } = getComboBoxContext(event.target); + hideList(comboBoxEl); + resetSelection(comboBoxEl); + inputEl.focus(); +}; + +/** + * Handle the down event within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ +const handleDownFromInput = event => { + const { + comboBoxEl, + listEl + } = getComboBoxContext(event.target); + if (listEl.hidden) { + displayList(comboBoxEl); + } + const nextOptionEl = listEl.querySelector(LIST_OPTION_FOCUSED) || listEl.querySelector(LIST_OPTION); + if (nextOptionEl) { + highlightOption(comboBoxEl, nextOptionEl); + } + event.preventDefault(); +}; + +/** + * Handle the enter event from an input element within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ +const handleEnterFromInput = event => { + const { + comboBoxEl, + listEl + } = getComboBoxContext(event.target); + const listShown = !listEl.hidden; + completeSelection(comboBoxEl); + if (listShown) { + hideList(comboBoxEl); + } + event.preventDefault(); +}; + +/** + * Handle the down event within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ +const handleDownFromListOption = event => { + const focusedOptionEl = event.target; + const nextOptionEl = focusedOptionEl.nextSibling; + if (nextOptionEl) { + highlightOption(focusedOptionEl, nextOptionEl); + } + event.preventDefault(); +}; + +/** + * Handle the tab event from an list option element within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ +const handleTabFromListOption = event => { + selectItem(event.target); + event.preventDefault(); +}; + +/** + * Handle the enter event from list option within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ +const handleEnterFromListOption = event => { + selectItem(event.target); + event.preventDefault(); +}; + +/** + * Handle the up event from list option within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ +const handleUpFromListOption = event => { + const { + comboBoxEl, + listEl, + focusedOptionEl + } = getComboBoxContext(event.target); + const nextOptionEl = focusedOptionEl && focusedOptionEl.previousSibling; + const listShown = !listEl.hidden; + highlightOption(comboBoxEl, nextOptionEl); + if (listShown) { + event.preventDefault(); + } + if (!nextOptionEl) { + hideList(comboBoxEl); + } +}; + +/** + * Select list option on the mouseover event. + * + * @param {MouseEvent} event The mouseover event + * @param {HTMLLIElement} listOptionEl An element within the combo box component + */ +const handleMouseover = listOptionEl => { + const isCurrentlyFocused = listOptionEl.classList.contains(LIST_OPTION_FOCUSED_CLASS); + if (isCurrentlyFocused) return; + highlightOption(listOptionEl, listOptionEl, { + preventScroll: true + }); +}; + +/** + * Toggle the list when the button is clicked + * + * @param {HTMLElement} el An element within the combo box component + */ +const toggleList = el => { + const { + comboBoxEl, + listEl, + inputEl + } = getComboBoxContext(el); + if (listEl.hidden) { + displayList(comboBoxEl); + } else { + hideList(comboBoxEl); + } + inputEl.focus(); +}; + +/** + * Handle click from input + * + * @param {HTMLInputElement} el An element within the combo box component + */ +const handleClickFromInput = el => { + const { + comboBoxEl, + listEl + } = getComboBoxContext(el); + if (listEl.hidden) { + displayList(comboBoxEl); + } +}; +const comboBox = behavior({ + [CLICK]: { + [INPUT]() { + if (this.disabled) return; + handleClickFromInput(this); + }, + [TOGGLE_LIST_BUTTON]() { + if (this.disabled) return; + toggleList(this); + }, + [LIST_OPTION]() { + if (this.disabled) return; + selectItem(this); + }, + [CLEAR_INPUT_BUTTON]() { + if (this.disabled) return; + clearInput(this); + } + }, + focusout: { + [COMBO_BOX](event) { + if (!this.contains(event.relatedTarget)) { + resetSelection(this); + hideList(this); + } + } + }, + keydown: { + [COMBO_BOX]: keymap({ + Escape: handleEscape + }), + [INPUT]: keymap({ + Enter: handleEnterFromInput, + ArrowDown: handleDownFromInput, + Down: handleDownFromInput + }), + [LIST_OPTION]: keymap({ + ArrowUp: handleUpFromListOption, + Up: handleUpFromListOption, + ArrowDown: handleDownFromListOption, + Down: handleDownFromListOption, + Enter: handleEnterFromListOption, + Tab: handleTabFromListOption, + "Shift+Tab": noop + }) + }, + input: { + [INPUT]() { + const comboBoxEl = this.closest(COMBO_BOX); + comboBoxEl.classList.remove(COMBO_BOX_PRISTINE_CLASS); + displayList(this); + } + }, + mouseover: { + [LIST_OPTION]() { + handleMouseover(this); + } + } +}, { + init(root) { + selectOrMatches(COMBO_BOX, root).forEach(comboBoxEl => { + enhanceComboBox(comboBoxEl); + }); + }, + getComboBoxContext, + enhanceComboBox, + generateDynamicRegExp, + disable, + enable, + displayList, + hideList, + COMBO_BOX_CLASS +}); +module.exports = comboBox; + +},{"../../uswds-core/src/js/config":35,"../../uswds-core/src/js/events":36,"../../uswds-core/src/js/utils/behavior":45,"../../uswds-core/src/js/utils/sanitizer":50,"../../uswds-core/src/js/utils/select-or-matches":52,"receptor/keymap":11}],19:[function(require,module,exports){ +"use strict"; + +const keymap = require("receptor/keymap"); +const behavior = require("../../uswds-core/src/js/utils/behavior"); +const select = require("../../uswds-core/src/js/utils/select"); +const selectOrMatches = require("../../uswds-core/src/js/utils/select-or-matches"); +const { + prefix: PREFIX +} = require("../../uswds-core/src/js/config"); +const { + CLICK +} = require("../../uswds-core/src/js/events"); +const activeElement = require("../../uswds-core/src/js/utils/active-element"); +const isIosDevice = require("../../uswds-core/src/js/utils/is-ios-device"); +const Sanitizer = require("../../uswds-core/src/js/utils/sanitizer"); +const DATE_PICKER_CLASS = `${PREFIX}-date-picker`; +const DATE_PICKER_WRAPPER_CLASS = `${DATE_PICKER_CLASS}__wrapper`; +const DATE_PICKER_INITIALIZED_CLASS = `${DATE_PICKER_CLASS}--initialized`; +const DATE_PICKER_ACTIVE_CLASS = `${DATE_PICKER_CLASS}--active`; +const DATE_PICKER_INTERNAL_INPUT_CLASS = `${DATE_PICKER_CLASS}__internal-input`; +const DATE_PICKER_EXTERNAL_INPUT_CLASS = `${DATE_PICKER_CLASS}__external-input`; +const DATE_PICKER_BUTTON_CLASS = `${DATE_PICKER_CLASS}__button`; +const DATE_PICKER_CALENDAR_CLASS = `${DATE_PICKER_CLASS}__calendar`; +const DATE_PICKER_STATUS_CLASS = `${DATE_PICKER_CLASS}__status`; +const CALENDAR_DATE_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__date`; +const CALENDAR_DATE_FOCUSED_CLASS = `${CALENDAR_DATE_CLASS}--focused`; +const CALENDAR_DATE_SELECTED_CLASS = `${CALENDAR_DATE_CLASS}--selected`; +const CALENDAR_DATE_PREVIOUS_MONTH_CLASS = `${CALENDAR_DATE_CLASS}--previous-month`; +const CALENDAR_DATE_CURRENT_MONTH_CLASS = `${CALENDAR_DATE_CLASS}--current-month`; +const CALENDAR_DATE_NEXT_MONTH_CLASS = `${CALENDAR_DATE_CLASS}--next-month`; +const CALENDAR_DATE_RANGE_DATE_CLASS = `${CALENDAR_DATE_CLASS}--range-date`; +const CALENDAR_DATE_TODAY_CLASS = `${CALENDAR_DATE_CLASS}--today`; +const CALENDAR_DATE_RANGE_DATE_START_CLASS = `${CALENDAR_DATE_CLASS}--range-date-start`; +const CALENDAR_DATE_RANGE_DATE_END_CLASS = `${CALENDAR_DATE_CLASS}--range-date-end`; +const CALENDAR_DATE_WITHIN_RANGE_CLASS = `${CALENDAR_DATE_CLASS}--within-range`; +const CALENDAR_PREVIOUS_YEAR_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__previous-year`; +const CALENDAR_PREVIOUS_MONTH_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__previous-month`; +const CALENDAR_NEXT_YEAR_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__next-year`; +const CALENDAR_NEXT_MONTH_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__next-month`; +const CALENDAR_MONTH_SELECTION_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__month-selection`; +const CALENDAR_YEAR_SELECTION_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__year-selection`; +const CALENDAR_MONTH_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__month`; +const CALENDAR_MONTH_FOCUSED_CLASS = `${CALENDAR_MONTH_CLASS}--focused`; +const CALENDAR_MONTH_SELECTED_CLASS = `${CALENDAR_MONTH_CLASS}--selected`; +const CALENDAR_YEAR_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__year`; +const CALENDAR_YEAR_FOCUSED_CLASS = `${CALENDAR_YEAR_CLASS}--focused`; +const CALENDAR_YEAR_SELECTED_CLASS = `${CALENDAR_YEAR_CLASS}--selected`; +const CALENDAR_PREVIOUS_YEAR_CHUNK_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__previous-year-chunk`; +const CALENDAR_NEXT_YEAR_CHUNK_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__next-year-chunk`; +const CALENDAR_DATE_PICKER_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__date-picker`; +const CALENDAR_MONTH_PICKER_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__month-picker`; +const CALENDAR_YEAR_PICKER_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__year-picker`; +const CALENDAR_TABLE_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__table`; +const CALENDAR_ROW_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__row`; +const CALENDAR_CELL_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__cell`; +const CALENDAR_CELL_CENTER_ITEMS_CLASS = `${CALENDAR_CELL_CLASS}--center-items`; +const CALENDAR_MONTH_LABEL_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__month-label`; +const CALENDAR_DAY_OF_WEEK_CLASS = `${DATE_PICKER_CALENDAR_CLASS}__day-of-week`; +const DATE_PICKER = `.${DATE_PICKER_CLASS}`; +const DATE_PICKER_BUTTON = `.${DATE_PICKER_BUTTON_CLASS}`; +const DATE_PICKER_INTERNAL_INPUT = `.${DATE_PICKER_INTERNAL_INPUT_CLASS}`; +const DATE_PICKER_EXTERNAL_INPUT = `.${DATE_PICKER_EXTERNAL_INPUT_CLASS}`; +const DATE_PICKER_CALENDAR = `.${DATE_PICKER_CALENDAR_CLASS}`; +const DATE_PICKER_STATUS = `.${DATE_PICKER_STATUS_CLASS}`; +const CALENDAR_DATE = `.${CALENDAR_DATE_CLASS}`; +const CALENDAR_DATE_FOCUSED = `.${CALENDAR_DATE_FOCUSED_CLASS}`; +const CALENDAR_DATE_CURRENT_MONTH = `.${CALENDAR_DATE_CURRENT_MONTH_CLASS}`; +const CALENDAR_PREVIOUS_YEAR = `.${CALENDAR_PREVIOUS_YEAR_CLASS}`; +const CALENDAR_PREVIOUS_MONTH = `.${CALENDAR_PREVIOUS_MONTH_CLASS}`; +const CALENDAR_NEXT_YEAR = `.${CALENDAR_NEXT_YEAR_CLASS}`; +const CALENDAR_NEXT_MONTH = `.${CALENDAR_NEXT_MONTH_CLASS}`; +const CALENDAR_YEAR_SELECTION = `.${CALENDAR_YEAR_SELECTION_CLASS}`; +const CALENDAR_MONTH_SELECTION = `.${CALENDAR_MONTH_SELECTION_CLASS}`; +const CALENDAR_MONTH = `.${CALENDAR_MONTH_CLASS}`; +const CALENDAR_YEAR = `.${CALENDAR_YEAR_CLASS}`; +const CALENDAR_PREVIOUS_YEAR_CHUNK = `.${CALENDAR_PREVIOUS_YEAR_CHUNK_CLASS}`; +const CALENDAR_NEXT_YEAR_CHUNK = `.${CALENDAR_NEXT_YEAR_CHUNK_CLASS}`; +const CALENDAR_DATE_PICKER = `.${CALENDAR_DATE_PICKER_CLASS}`; +const CALENDAR_MONTH_PICKER = `.${CALENDAR_MONTH_PICKER_CLASS}`; +const CALENDAR_YEAR_PICKER = `.${CALENDAR_YEAR_PICKER_CLASS}`; +const CALENDAR_MONTH_FOCUSED = `.${CALENDAR_MONTH_FOCUSED_CLASS}`; +const CALENDAR_YEAR_FOCUSED = `.${CALENDAR_YEAR_FOCUSED_CLASS}`; +const VALIDATION_MESSAGE = "Please enter a valid date"; +const MONTH_LABELS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; +const DAY_OF_WEEK_LABELS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; +const ENTER_KEYCODE = 13; +const YEAR_CHUNK = 12; +const DEFAULT_MIN_DATE = "0000-01-01"; +const DEFAULT_EXTERNAL_DATE_FORMAT = "MM/DD/YYYY"; +const INTERNAL_DATE_FORMAT = "YYYY-MM-DD"; +const NOT_DISABLED_SELECTOR = ":not([disabled])"; +const processFocusableSelectors = function () { + for (var _len = arguments.length, selectors = new Array(_len), _key = 0; _key < _len; _key++) { + selectors[_key] = arguments[_key]; + } + return selectors.map(query => query + NOT_DISABLED_SELECTOR).join(", "); +}; +const DATE_PICKER_FOCUSABLE = processFocusableSelectors(CALENDAR_PREVIOUS_YEAR, CALENDAR_PREVIOUS_MONTH, CALENDAR_YEAR_SELECTION, CALENDAR_MONTH_SELECTION, CALENDAR_NEXT_YEAR, CALENDAR_NEXT_MONTH, CALENDAR_DATE_FOCUSED); +const MONTH_PICKER_FOCUSABLE = processFocusableSelectors(CALENDAR_MONTH_FOCUSED); +const YEAR_PICKER_FOCUSABLE = processFocusableSelectors(CALENDAR_PREVIOUS_YEAR_CHUNK, CALENDAR_NEXT_YEAR_CHUNK, CALENDAR_YEAR_FOCUSED); + +// #region Date Manipulation Functions + +/** + * Keep date within month. Month would only be over by 1 to 3 days + * + * @param {Date} dateToCheck the date object to check + * @param {number} month the correct month + * @returns {Date} the date, corrected if needed + */ +const keepDateWithinMonth = (dateToCheck, month) => { + if (month !== dateToCheck.getMonth()) { + dateToCheck.setDate(0); + } + return dateToCheck; +}; + +/** + * Set date from month day year + * + * @param {number} year the year to set + * @param {number} month the month to set (zero-indexed) + * @param {number} date the date to set + * @returns {Date} the set date + */ +const setDate = (year, month, date) => { + const newDate = new Date(0); + newDate.setFullYear(year, month, date); + return newDate; +}; + +/** + * todays date + * + * @returns {Date} todays date + */ +const today = () => { + const newDate = new Date(); + const day = newDate.getDate(); + const month = newDate.getMonth(); + const year = newDate.getFullYear(); + return setDate(year, month, day); +}; + +/** + * Set date to first day of the month + * + * @param {number} date the date to adjust + * @returns {Date} the adjusted date + */ +const startOfMonth = date => { + const newDate = new Date(0); + newDate.setFullYear(date.getFullYear(), date.getMonth(), 1); + return newDate; +}; + +/** + * Set date to last day of the month + * + * @param {number} date the date to adjust + * @returns {Date} the adjusted date + */ +const lastDayOfMonth = date => { + const newDate = new Date(0); + newDate.setFullYear(date.getFullYear(), date.getMonth() + 1, 0); + return newDate; +}; + +/** + * Add days to date + * + * @param {Date} _date the date to adjust + * @param {number} numDays the difference in days + * @returns {Date} the adjusted date + */ +const addDays = (_date, numDays) => { + const newDate = new Date(_date.getTime()); + newDate.setDate(newDate.getDate() + numDays); + return newDate; +}; + +/** + * Subtract days from date + * + * @param {Date} _date the date to adjust + * @param {number} numDays the difference in days + * @returns {Date} the adjusted date + */ +const subDays = (_date, numDays) => addDays(_date, -numDays); + +/** + * Add weeks to date + * + * @param {Date} _date the date to adjust + * @param {number} numWeeks the difference in weeks + * @returns {Date} the adjusted date + */ +const addWeeks = (_date, numWeeks) => addDays(_date, numWeeks * 7); + +/** + * Subtract weeks from date + * + * @param {Date} _date the date to adjust + * @param {number} numWeeks the difference in weeks + * @returns {Date} the adjusted date + */ +const subWeeks = (_date, numWeeks) => addWeeks(_date, -numWeeks); + +/** + * Set date to the start of the week (Sunday) + * + * @param {Date} _date the date to adjust + * @returns {Date} the adjusted date + */ +const startOfWeek = _date => { + const dayOfWeek = _date.getDay(); + return subDays(_date, dayOfWeek); +}; + +/** + * Set date to the end of the week (Saturday) + * + * @param {Date} _date the date to adjust + * @param {number} numWeeks the difference in weeks + * @returns {Date} the adjusted date + */ +const endOfWeek = _date => { + const dayOfWeek = _date.getDay(); + return addDays(_date, 6 - dayOfWeek); +}; + +/** + * Add months to date and keep date within month + * + * @param {Date} _date the date to adjust + * @param {number} numMonths the difference in months + * @returns {Date} the adjusted date + */ +const addMonths = (_date, numMonths) => { + const newDate = new Date(_date.getTime()); + const dateMonth = (newDate.getMonth() + 12 + numMonths) % 12; + newDate.setMonth(newDate.getMonth() + numMonths); + keepDateWithinMonth(newDate, dateMonth); + return newDate; +}; + +/** + * Subtract months from date + * + * @param {Date} _date the date to adjust + * @param {number} numMonths the difference in months + * @returns {Date} the adjusted date + */ +const subMonths = (_date, numMonths) => addMonths(_date, -numMonths); + +/** + * Add years to date and keep date within month + * + * @param {Date} _date the date to adjust + * @param {number} numYears the difference in years + * @returns {Date} the adjusted date + */ +const addYears = (_date, numYears) => addMonths(_date, numYears * 12); + +/** + * Subtract years from date + * + * @param {Date} _date the date to adjust + * @param {number} numYears the difference in years + * @returns {Date} the adjusted date + */ +const subYears = (_date, numYears) => addYears(_date, -numYears); + +/** + * Set months of date + * + * @param {Date} _date the date to adjust + * @param {number} month zero-indexed month to set + * @returns {Date} the adjusted date + */ +const setMonth = (_date, month) => { + const newDate = new Date(_date.getTime()); + newDate.setMonth(month); + keepDateWithinMonth(newDate, month); + return newDate; +}; + +/** + * Set year of date + * + * @param {Date} _date the date to adjust + * @param {number} year the year to set + * @returns {Date} the adjusted date + */ +const setYear = (_date, year) => { + const newDate = new Date(_date.getTime()); + const month = newDate.getMonth(); + newDate.setFullYear(year); + keepDateWithinMonth(newDate, month); + return newDate; +}; + +/** + * Return the earliest date + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {Date} the earliest date + */ +const min = (dateA, dateB) => { + let newDate = dateA; + if (dateB < dateA) { + newDate = dateB; + } + return new Date(newDate.getTime()); +}; + +/** + * Return the latest date + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {Date} the latest date + */ +const max = (dateA, dateB) => { + let newDate = dateA; + if (dateB > dateA) { + newDate = dateB; + } + return new Date(newDate.getTime()); +}; + +/** + * Check if dates are the in the same year + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {boolean} are dates in the same year + */ +const isSameYear = (dateA, dateB) => dateA && dateB && dateA.getFullYear() === dateB.getFullYear(); + +/** + * Check if dates are the in the same month + * + * @param {Date} dateA date to compare + * @param {Date} dateB date to compare + * @returns {boolean} are dates in the same month + */ +const isSameMonth = (dateA, dateB) => isSameYear(dateA, dateB) && dateA.getMonth() === dateB.getMonth(); + +/** + * Check if dates are the same date + * + * @param {Date} dateA the date to compare + * @param {Date} dateA the date to compare + * @returns {boolean} are dates the same date + */ +const isSameDay = (dateA, dateB) => isSameMonth(dateA, dateB) && dateA.getDate() === dateB.getDate(); + +/** + * return a new date within minimum and maximum date + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @returns {Date} the date between min and max + */ +const keepDateBetweenMinAndMax = (date, minDate, maxDate) => { + let newDate = date; + if (date < minDate) { + newDate = minDate; + } else if (maxDate && date > maxDate) { + newDate = maxDate; + } + return new Date(newDate.getTime()); +}; + +/** + * Check if dates is valid. + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @return {boolean} is there a day within the month within min and max dates + */ +const isDateWithinMinAndMax = (date, minDate, maxDate) => date >= minDate && (!maxDate || date <= maxDate); + +/** + * Check if dates month is invalid. + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @return {boolean} is the month outside min or max dates + */ +const isDatesMonthOutsideMinOrMax = (date, minDate, maxDate) => lastDayOfMonth(date) < minDate || maxDate && startOfMonth(date) > maxDate; + +/** + * Check if dates year is invalid. + * + * @param {Date} date date to check + * @param {Date} minDate minimum date to allow + * @param {Date} maxDate maximum date to allow + * @return {boolean} is the month outside min or max dates + */ +const isDatesYearOutsideMinOrMax = (date, minDate, maxDate) => lastDayOfMonth(setMonth(date, 11)) < minDate || maxDate && startOfMonth(setMonth(date, 0)) > maxDate; + +/** + * Parse a date with format M-D-YY + * + * @param {string} dateString the date string to parse + * @param {string} dateFormat the format of the date string + * @param {boolean} adjustDate should the date be adjusted + * @returns {Date} the parsed date + */ +const parseDateString = function (dateString) { + let dateFormat = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : INTERNAL_DATE_FORMAT; + let adjustDate = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; + let date; + let month; + let day; + let year; + let parsed; + if (dateString) { + let monthStr; + let dayStr; + let yearStr; + if (dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT) { + [monthStr, dayStr, yearStr] = dateString.split("/"); + } else { + [yearStr, monthStr, dayStr] = dateString.split("-"); + } + if (yearStr) { + parsed = parseInt(yearStr, 10); + if (!Number.isNaN(parsed)) { + year = parsed; + if (adjustDate) { + year = Math.max(0, year); + if (yearStr.length < 3) { + const currentYear = today().getFullYear(); + const currentYearStub = currentYear - currentYear % 10 ** yearStr.length; + year = currentYearStub + parsed; + } + } + } + } + if (monthStr) { + parsed = parseInt(monthStr, 10); + if (!Number.isNaN(parsed)) { + month = parsed; + if (adjustDate) { + month = Math.max(1, month); + month = Math.min(12, month); + } + } + } + if (month && dayStr && year != null) { + parsed = parseInt(dayStr, 10); + if (!Number.isNaN(parsed)) { + day = parsed; + if (adjustDate) { + const lastDayOfTheMonth = setDate(year, month, 0).getDate(); + day = Math.max(1, day); + day = Math.min(lastDayOfTheMonth, day); + } + } + } + if (month && day && year != null) { + date = setDate(year, month - 1, day); + } + } + return date; +}; + +/** + * Format a date to format MM-DD-YYYY + * + * @param {Date} date the date to format + * @param {string} dateFormat the format of the date string + * @returns {string} the formatted date string + */ +const formatDate = function (date) { + let dateFormat = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : INTERNAL_DATE_FORMAT; + const padZeros = (value, length) => `0000${value}`.slice(-length); + const month = date.getMonth() + 1; + const day = date.getDate(); + const year = date.getFullYear(); + if (dateFormat === DEFAULT_EXTERNAL_DATE_FORMAT) { + return [padZeros(month, 2), padZeros(day, 2), padZeros(year, 4)].join("/"); + } + return [padZeros(year, 4), padZeros(month, 2), padZeros(day, 2)].join("-"); +}; + +// #endregion Date Manipulation Functions + +/** + * Create a grid string from an array of html strings + * + * @param {string[]} htmlArray the array of html items + * @param {number} rowSize the length of a row + * @returns {string} the grid string + */ +const listToGridHtml = (htmlArray, rowSize) => { + const grid = []; + let row = []; + let i = 0; + while (i < htmlArray.length) { + row = []; + const tr = document.createElement("tr"); + while (i < htmlArray.length && row.length < rowSize) { + const td = document.createElement("td"); + td.insertAdjacentElement("beforeend", htmlArray[i]); + row.push(td); + i += 1; + } + row.forEach(element => { + tr.insertAdjacentElement("beforeend", element); + }); + grid.push(tr); + } + return grid; +}; +const createTableBody = grid => { + const tableBody = document.createElement("tbody"); + grid.forEach(element => { + tableBody.insertAdjacentElement("beforeend", element); + }); + return tableBody; +}; + +/** + * set the value of the element and dispatch a change event + * + * @param {HTMLInputElement} el The element to update + * @param {string} value The new value of the element + */ +const changeElementValue = function (el) { + let value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ""; + const elementToChange = el; + elementToChange.value = value; + const event = new CustomEvent("change", { + bubbles: true, + cancelable: true, + detail: { + value + } + }); + elementToChange.dispatchEvent(event); +}; + +/** + * The properties and elements within the date picker. + * @typedef {Object} DatePickerContext + * @property {HTMLDivElement} calendarEl + * @property {HTMLElement} datePickerEl + * @property {HTMLInputElement} internalInputEl + * @property {HTMLInputElement} externalInputEl + * @property {HTMLDivElement} statusEl + * @property {HTMLDivElement} firstYearChunkEl + * @property {Date} calendarDate + * @property {Date} minDate + * @property {Date} maxDate + * @property {Date} selectedDate + * @property {Date} rangeDate + * @property {Date} defaultDate + */ + +/** + * Get an object of the properties and elements belonging directly to the given + * date picker component. + * + * @param {HTMLElement} el the element within the date picker + * @returns {DatePickerContext} elements + */ +const getDatePickerContext = el => { + const datePickerEl = el.closest(DATE_PICKER); + if (!datePickerEl) { + throw new Error(`Element is missing outer ${DATE_PICKER}`); + } + const internalInputEl = datePickerEl.querySelector(DATE_PICKER_INTERNAL_INPUT); + const externalInputEl = datePickerEl.querySelector(DATE_PICKER_EXTERNAL_INPUT); + const calendarEl = datePickerEl.querySelector(DATE_PICKER_CALENDAR); + const toggleBtnEl = datePickerEl.querySelector(DATE_PICKER_BUTTON); + const statusEl = datePickerEl.querySelector(DATE_PICKER_STATUS); + const firstYearChunkEl = datePickerEl.querySelector(CALENDAR_YEAR); + const inputDate = parseDateString(externalInputEl.value, DEFAULT_EXTERNAL_DATE_FORMAT, true); + const selectedDate = parseDateString(internalInputEl.value); + const calendarDate = parseDateString(calendarEl.dataset.value); + const minDate = parseDateString(datePickerEl.dataset.minDate); + const maxDate = parseDateString(datePickerEl.dataset.maxDate); + const rangeDate = parseDateString(datePickerEl.dataset.rangeDate); + const defaultDate = parseDateString(datePickerEl.dataset.defaultDate); + if (minDate && maxDate && minDate > maxDate) { + throw new Error("Minimum date cannot be after maximum date"); + } + return { + calendarDate, + minDate, + toggleBtnEl, + selectedDate, + maxDate, + firstYearChunkEl, + datePickerEl, + inputDate, + internalInputEl, + externalInputEl, + calendarEl, + rangeDate, + defaultDate, + statusEl + }; +}; + +/** + * Disable the date picker component + * + * @param {HTMLElement} el An element within the date picker component + */ +const disable = el => { + const { + externalInputEl, + toggleBtnEl + } = getDatePickerContext(el); + toggleBtnEl.disabled = true; + externalInputEl.disabled = true; +}; + +/** + * Enable the date picker component + * + * @param {HTMLElement} el An element within the date picker component + */ +const enable = el => { + const { + externalInputEl, + toggleBtnEl + } = getDatePickerContext(el); + toggleBtnEl.disabled = false; + externalInputEl.disabled = false; +}; + +// #region Validation + +/** + * Validate the value in the input as a valid date of format M/D/YYYY + * + * @param {HTMLElement} el An element within the date picker component + */ +const isDateInputInvalid = el => { + const { + externalInputEl, + minDate, + maxDate + } = getDatePickerContext(el); + const dateString = externalInputEl.value; + let isInvalid = false; + if (dateString) { + isInvalid = true; + const dateStringParts = dateString.split("/"); + const [month, day, year] = dateStringParts.map(str => { + let value; + const parsed = parseInt(str, 10); + if (!Number.isNaN(parsed)) value = parsed; + return value; + }); + if (month && day && year != null) { + const checkDate = setDate(year, month - 1, day); + if (checkDate.getMonth() === month - 1 && checkDate.getDate() === day && checkDate.getFullYear() === year && dateStringParts[2].length === 4 && isDateWithinMinAndMax(checkDate, minDate, maxDate)) { + isInvalid = false; + } + } + } + return isInvalid; +}; + +/** + * Validate the value in the input as a valid date of format M/D/YYYY + * + * @param {HTMLElement} el An element within the date picker component + */ +const validateDateInput = el => { + const { + externalInputEl + } = getDatePickerContext(el); + const isInvalid = isDateInputInvalid(externalInputEl); + if (isInvalid && !externalInputEl.validationMessage) { + externalInputEl.setCustomValidity(VALIDATION_MESSAGE); + } + if (!isInvalid && externalInputEl.validationMessage === VALIDATION_MESSAGE) { + externalInputEl.setCustomValidity(""); + } +}; + +// #endregion Validation + +/** + * Enable the date picker component + * + * @param {HTMLElement} el An element within the date picker component + */ +const reconcileInputValues = el => { + const { + internalInputEl, + inputDate + } = getDatePickerContext(el); + let newValue = ""; + if (inputDate && !isDateInputInvalid(el)) { + newValue = formatDate(inputDate); + } + if (internalInputEl.value !== newValue) { + changeElementValue(internalInputEl, newValue); + } +}; + +/** + * Select the value of the date picker inputs. + * + * @param {HTMLButtonElement} el An element within the date picker component + * @param {string} dateString The date string to update in YYYY-MM-DD format + */ +const setCalendarValue = (el, dateString) => { + const parsedDate = parseDateString(dateString); + if (parsedDate) { + const formattedDate = formatDate(parsedDate, DEFAULT_EXTERNAL_DATE_FORMAT); + const { + datePickerEl, + internalInputEl, + externalInputEl + } = getDatePickerContext(el); + changeElementValue(internalInputEl, dateString); + changeElementValue(externalInputEl, formattedDate); + validateDateInput(datePickerEl); + } +}; + +/** + * Enhance an input with the date picker elements + * + * @param {HTMLElement} el The initial wrapping element of the date picker component + */ +const enhanceDatePicker = el => { + const datePickerEl = el.closest(DATE_PICKER); + const { + defaultValue + } = datePickerEl.dataset; + const internalInputEl = datePickerEl.querySelector(`input`); + if (!internalInputEl) { + throw new Error(`${DATE_PICKER} is missing inner input`); + } + if (internalInputEl.value) { + internalInputEl.value = ""; + } + const minDate = parseDateString(datePickerEl.dataset.minDate || internalInputEl.getAttribute("min")); + datePickerEl.dataset.minDate = minDate ? formatDate(minDate) : DEFAULT_MIN_DATE; + const maxDate = parseDateString(datePickerEl.dataset.maxDate || internalInputEl.getAttribute("max")); + if (maxDate) { + datePickerEl.dataset.maxDate = formatDate(maxDate); + } + const calendarWrapper = document.createElement("div"); + calendarWrapper.classList.add(DATE_PICKER_WRAPPER_CLASS); + const externalInputEl = internalInputEl.cloneNode(); + externalInputEl.classList.add(DATE_PICKER_EXTERNAL_INPUT_CLASS); + externalInputEl.type = "text"; + calendarWrapper.appendChild(externalInputEl); + calendarWrapper.insertAdjacentHTML("beforeend", Sanitizer.escapeHTML` + + +
    `); + internalInputEl.setAttribute("aria-hidden", "true"); + internalInputEl.setAttribute("tabindex", "-1"); + internalInputEl.style.display = "none"; + internalInputEl.classList.add(DATE_PICKER_INTERNAL_INPUT_CLASS); + internalInputEl.removeAttribute("id"); + internalInputEl.removeAttribute("name"); + internalInputEl.required = false; + datePickerEl.appendChild(calendarWrapper); + datePickerEl.classList.add(DATE_PICKER_INITIALIZED_CLASS); + if (defaultValue) { + setCalendarValue(datePickerEl, defaultValue); + } + if (internalInputEl.disabled) { + disable(datePickerEl); + internalInputEl.disabled = false; + } +}; + +// #region Calendar - Date Selection View + +/** + * render the calendar. + * + * @param {HTMLElement} el An element within the date picker component + * @param {Date} _dateToDisplay a date to render on the calendar + * @returns {HTMLElement} a reference to the new calendar element + */ +const renderCalendar = (el, _dateToDisplay) => { + const { + datePickerEl, + calendarEl, + statusEl, + selectedDate, + maxDate, + minDate, + rangeDate + } = getDatePickerContext(el); + const todaysDate = today(); + let dateToDisplay = _dateToDisplay || todaysDate; + const calendarWasHidden = calendarEl.hidden; + const focusedDate = addDays(dateToDisplay, 0); + const focusedMonth = dateToDisplay.getMonth(); + const focusedYear = dateToDisplay.getFullYear(); + const prevMonth = subMonths(dateToDisplay, 1); + const nextMonth = addMonths(dateToDisplay, 1); + const currentFormattedDate = formatDate(dateToDisplay); + const firstOfMonth = startOfMonth(dateToDisplay); + const prevButtonsDisabled = isSameMonth(dateToDisplay, minDate); + const nextButtonsDisabled = isSameMonth(dateToDisplay, maxDate); + const rangeConclusionDate = selectedDate || dateToDisplay; + const rangeStartDate = rangeDate && min(rangeConclusionDate, rangeDate); + const rangeEndDate = rangeDate && max(rangeConclusionDate, rangeDate); + const withinRangeStartDate = rangeDate && addDays(rangeStartDate, 1); + const withinRangeEndDate = rangeDate && subDays(rangeEndDate, 1); + const monthLabel = MONTH_LABELS[focusedMonth]; + const generateDateHtml = dateToRender => { + const classes = [CALENDAR_DATE_CLASS]; + const day = dateToRender.getDate(); + const month = dateToRender.getMonth(); + const year = dateToRender.getFullYear(); + const dayOfWeek = dateToRender.getDay(); + const formattedDate = formatDate(dateToRender); + let tabindex = "-1"; + const isDisabled = !isDateWithinMinAndMax(dateToRender, minDate, maxDate); + const isSelected = isSameDay(dateToRender, selectedDate); + if (isSameMonth(dateToRender, prevMonth)) { + classes.push(CALENDAR_DATE_PREVIOUS_MONTH_CLASS); + } + if (isSameMonth(dateToRender, focusedDate)) { + classes.push(CALENDAR_DATE_CURRENT_MONTH_CLASS); + } + if (isSameMonth(dateToRender, nextMonth)) { + classes.push(CALENDAR_DATE_NEXT_MONTH_CLASS); + } + if (isSelected) { + classes.push(CALENDAR_DATE_SELECTED_CLASS); + } + if (isSameDay(dateToRender, todaysDate)) { + classes.push(CALENDAR_DATE_TODAY_CLASS); + } + if (rangeDate) { + if (isSameDay(dateToRender, rangeDate)) { + classes.push(CALENDAR_DATE_RANGE_DATE_CLASS); + } + if (isSameDay(dateToRender, rangeStartDate)) { + classes.push(CALENDAR_DATE_RANGE_DATE_START_CLASS); + } + if (isSameDay(dateToRender, rangeEndDate)) { + classes.push(CALENDAR_DATE_RANGE_DATE_END_CLASS); + } + if (isDateWithinMinAndMax(dateToRender, withinRangeStartDate, withinRangeEndDate)) { + classes.push(CALENDAR_DATE_WITHIN_RANGE_CLASS); + } + } + if (isSameDay(dateToRender, focusedDate)) { + tabindex = "0"; + classes.push(CALENDAR_DATE_FOCUSED_CLASS); + } + const monthStr = MONTH_LABELS[month]; + const dayStr = DAY_OF_WEEK_LABELS[dayOfWeek]; + const btn = document.createElement("button"); + btn.setAttribute("type", "button"); + btn.setAttribute("tabindex", tabindex); + btn.setAttribute("class", classes.join(" ")); + btn.setAttribute("data-day", day); + btn.setAttribute("data-month", month + 1); + btn.setAttribute("data-year", year); + btn.setAttribute("data-value", formattedDate); + btn.setAttribute("aria-label", Sanitizer.escapeHTML`${day} ${monthStr} ${year} ${dayStr}`); + btn.setAttribute("aria-selected", isSelected ? "true" : "false"); + if (isDisabled === true) { + btn.disabled = true; + } + btn.textContent = day; + return btn; + }; + + // set date to first rendered day + dateToDisplay = startOfWeek(firstOfMonth); + const days = []; + while (days.length < 28 || dateToDisplay.getMonth() === focusedMonth || days.length % 7 !== 0) { + days.push(generateDateHtml(dateToDisplay)); + dateToDisplay = addDays(dateToDisplay, 1); + } + const datesGrid = listToGridHtml(days, 7); + const newCalendar = calendarEl.cloneNode(); + newCalendar.dataset.value = currentFormattedDate; + newCalendar.style.top = `${datePickerEl.offsetHeight}px`; + newCalendar.hidden = false; + newCalendar.innerHTML = Sanitizer.escapeHTML` +
    +
    +
    + +
    +
    + +
    +
    + + +
    +
    + +
    +
    + +
    +
    +
    + `; + const table = document.createElement("table"); + table.setAttribute("class", CALENDAR_TABLE_CLASS); + table.setAttribute("role", "presentation"); + const tableHead = document.createElement("thead"); + table.insertAdjacentElement("beforeend", tableHead); + const tableHeadRow = document.createElement("tr"); + tableHead.insertAdjacentElement("beforeend", tableHeadRow); + const daysOfWeek = { + Sunday: "S", + Monday: "M", + Tuesday: "T", + Wednesday: "W", + Thursday: "Th", + Friday: "Fr", + Saturday: "S" + }; + Object.keys(daysOfWeek).forEach(key => { + const th = document.createElement("th"); + th.setAttribute("class", CALENDAR_DAY_OF_WEEK_CLASS); + th.setAttribute("scope", "presentation"); + th.setAttribute("aria-label", key); + th.textContent = daysOfWeek[key]; + tableHeadRow.insertAdjacentElement("beforeend", th); + }); + const tableBody = createTableBody(datesGrid); + table.insertAdjacentElement("beforeend", tableBody); + + // Container for Years, Months, and Days + const datePickerCalendarContainer = newCalendar.querySelector(CALENDAR_DATE_PICKER); + datePickerCalendarContainer.insertAdjacentElement("beforeend", table); + calendarEl.parentNode.replaceChild(newCalendar, calendarEl); + datePickerEl.classList.add(DATE_PICKER_ACTIVE_CLASS); + const statuses = []; + if (isSameDay(selectedDate, focusedDate)) { + statuses.push("Selected date"); + } + if (calendarWasHidden) { + statuses.push("You can navigate by day using left and right arrows", "Weeks by using up and down arrows", "Months by using page up and page down keys", "Years by using shift plus page up and shift plus page down", "Home and end keys navigate to the beginning and end of a week"); + statusEl.textContent = ""; + } else { + statuses.push(`${monthLabel} ${focusedYear}`); + } + statusEl.textContent = statuses.join(". "); + return newCalendar; +}; + +/** + * Navigate back one year and display the calendar. + * + * @param {HTMLButtonElement} _buttonEl An element within the date picker component + */ +const displayPreviousYear = _buttonEl => { + if (_buttonEl.disabled) return; + const { + calendarEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(_buttonEl); + let date = subYears(calendarDate, 1); + date = keepDateBetweenMinAndMax(date, minDate, maxDate); + const newCalendar = renderCalendar(calendarEl, date); + let nextToFocus = newCalendar.querySelector(CALENDAR_PREVIOUS_YEAR); + if (nextToFocus.disabled) { + nextToFocus = newCalendar.querySelector(CALENDAR_DATE_PICKER); + } + nextToFocus.focus(); +}; + +/** + * Navigate back one month and display the calendar. + * + * @param {HTMLButtonElement} _buttonEl An element within the date picker component + */ +const displayPreviousMonth = _buttonEl => { + if (_buttonEl.disabled) return; + const { + calendarEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(_buttonEl); + let date = subMonths(calendarDate, 1); + date = keepDateBetweenMinAndMax(date, minDate, maxDate); + const newCalendar = renderCalendar(calendarEl, date); + let nextToFocus = newCalendar.querySelector(CALENDAR_PREVIOUS_MONTH); + if (nextToFocus.disabled) { + nextToFocus = newCalendar.querySelector(CALENDAR_DATE_PICKER); + } + nextToFocus.focus(); +}; + +/** + * Navigate forward one month and display the calendar. + * + * @param {HTMLButtonElement} _buttonEl An element within the date picker component + */ +const displayNextMonth = _buttonEl => { + if (_buttonEl.disabled) return; + const { + calendarEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(_buttonEl); + let date = addMonths(calendarDate, 1); + date = keepDateBetweenMinAndMax(date, minDate, maxDate); + const newCalendar = renderCalendar(calendarEl, date); + let nextToFocus = newCalendar.querySelector(CALENDAR_NEXT_MONTH); + if (nextToFocus.disabled) { + nextToFocus = newCalendar.querySelector(CALENDAR_DATE_PICKER); + } + nextToFocus.focus(); +}; + +/** + * Navigate forward one year and display the calendar. + * + * @param {HTMLButtonElement} _buttonEl An element within the date picker component + */ +const displayNextYear = _buttonEl => { + if (_buttonEl.disabled) return; + const { + calendarEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(_buttonEl); + let date = addYears(calendarDate, 1); + date = keepDateBetweenMinAndMax(date, minDate, maxDate); + const newCalendar = renderCalendar(calendarEl, date); + let nextToFocus = newCalendar.querySelector(CALENDAR_NEXT_YEAR); + if (nextToFocus.disabled) { + nextToFocus = newCalendar.querySelector(CALENDAR_DATE_PICKER); + } + nextToFocus.focus(); +}; + +/** + * Hide the calendar of a date picker component. + * + * @param {HTMLElement} el An element within the date picker component + */ +const hideCalendar = el => { + const { + datePickerEl, + calendarEl, + statusEl + } = getDatePickerContext(el); + datePickerEl.classList.remove(DATE_PICKER_ACTIVE_CLASS); + calendarEl.hidden = true; + statusEl.textContent = ""; +}; + +/** + * Select a date within the date picker component. + * + * @param {HTMLButtonElement} calendarDateEl A date element within the date picker component + */ +const selectDate = calendarDateEl => { + if (calendarDateEl.disabled) return; + const { + datePickerEl, + externalInputEl + } = getDatePickerContext(calendarDateEl); + setCalendarValue(calendarDateEl, calendarDateEl.dataset.value); + hideCalendar(datePickerEl); + externalInputEl.focus(); +}; + +/** + * Toggle the calendar. + * + * @param {HTMLButtonElement} el An element within the date picker component + */ +const toggleCalendar = el => { + if (el.disabled) return; + const { + calendarEl, + inputDate, + minDate, + maxDate, + defaultDate + } = getDatePickerContext(el); + if (calendarEl.hidden) { + const dateToDisplay = keepDateBetweenMinAndMax(inputDate || defaultDate || today(), minDate, maxDate); + const newCalendar = renderCalendar(calendarEl, dateToDisplay); + newCalendar.querySelector(CALENDAR_DATE_FOCUSED).focus(); + } else { + hideCalendar(el); + } +}; + +/** + * Update the calendar when visible. + * + * @param {HTMLElement} el an element within the date picker + */ +const updateCalendarIfVisible = el => { + const { + calendarEl, + inputDate, + minDate, + maxDate + } = getDatePickerContext(el); + const calendarShown = !calendarEl.hidden; + if (calendarShown && inputDate) { + const dateToDisplay = keepDateBetweenMinAndMax(inputDate, minDate, maxDate); + renderCalendar(calendarEl, dateToDisplay); + } +}; + +// #endregion Calendar - Date Selection View + +// #region Calendar - Month Selection View +/** + * Display the month selection screen in the date picker. + * + * @param {HTMLButtonElement} el An element within the date picker component + * @returns {HTMLElement} a reference to the new calendar element + */ +const displayMonthSelection = (el, monthToDisplay) => { + const { + calendarEl, + statusEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(el); + const selectedMonth = calendarDate.getMonth(); + const focusedMonth = monthToDisplay == null ? selectedMonth : monthToDisplay; + const months = MONTH_LABELS.map((month, index) => { + const monthToCheck = setMonth(calendarDate, index); + const isDisabled = isDatesMonthOutsideMinOrMax(monthToCheck, minDate, maxDate); + let tabindex = "-1"; + const classes = [CALENDAR_MONTH_CLASS]; + const isSelected = index === selectedMonth; + if (index === focusedMonth) { + tabindex = "0"; + classes.push(CALENDAR_MONTH_FOCUSED_CLASS); + } + if (isSelected) { + classes.push(CALENDAR_MONTH_SELECTED_CLASS); + } + const btn = document.createElement("button"); + btn.setAttribute("type", "button"); + btn.setAttribute("tabindex", tabindex); + btn.setAttribute("class", classes.join(" ")); + btn.setAttribute("data-value", index); + btn.setAttribute("data-label", month); + btn.setAttribute("aria-selected", isSelected ? "true" : "false"); + if (isDisabled === true) { + btn.disabled = true; + } + btn.textContent = month; + return btn; + }); + const monthsHtml = document.createElement("div"); + monthsHtml.setAttribute("tabindex", "-1"); + monthsHtml.setAttribute("class", CALENDAR_MONTH_PICKER_CLASS); + const table = document.createElement("table"); + table.setAttribute("class", CALENDAR_TABLE_CLASS); + table.setAttribute("role", "presentation"); + const monthsGrid = listToGridHtml(months, 3); + const tableBody = createTableBody(monthsGrid); + table.insertAdjacentElement("beforeend", tableBody); + monthsHtml.insertAdjacentElement("beforeend", table); + const newCalendar = calendarEl.cloneNode(); + newCalendar.insertAdjacentElement("beforeend", monthsHtml); + calendarEl.parentNode.replaceChild(newCalendar, calendarEl); + statusEl.textContent = "Select a month."; + return newCalendar; +}; + +/** + * Select a month in the date picker component. + * + * @param {HTMLButtonElement} monthEl An month element within the date picker component + */ +const selectMonth = monthEl => { + if (monthEl.disabled) return; + const { + calendarEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(monthEl); + const selectedMonth = parseInt(monthEl.dataset.value, 10); + let date = setMonth(calendarDate, selectedMonth); + date = keepDateBetweenMinAndMax(date, minDate, maxDate); + const newCalendar = renderCalendar(calendarEl, date); + newCalendar.querySelector(CALENDAR_DATE_FOCUSED).focus(); +}; + +// #endregion Calendar - Month Selection View + +// #region Calendar - Year Selection View + +/** + * Display the year selection screen in the date picker. + * + * @param {HTMLButtonElement} el An element within the date picker component + * @param {number} yearToDisplay year to display in year selection + * @returns {HTMLElement} a reference to the new calendar element + */ +const displayYearSelection = (el, yearToDisplay) => { + const { + calendarEl, + statusEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(el); + const selectedYear = calendarDate.getFullYear(); + const focusedYear = yearToDisplay == null ? selectedYear : yearToDisplay; + let yearToChunk = focusedYear; + yearToChunk -= yearToChunk % YEAR_CHUNK; + yearToChunk = Math.max(0, yearToChunk); + const prevYearChunkDisabled = isDatesYearOutsideMinOrMax(setYear(calendarDate, yearToChunk - 1), minDate, maxDate); + const nextYearChunkDisabled = isDatesYearOutsideMinOrMax(setYear(calendarDate, yearToChunk + YEAR_CHUNK), minDate, maxDate); + const years = []; + let yearIndex = yearToChunk; + while (years.length < YEAR_CHUNK) { + const isDisabled = isDatesYearOutsideMinOrMax(setYear(calendarDate, yearIndex), minDate, maxDate); + let tabindex = "-1"; + const classes = [CALENDAR_YEAR_CLASS]; + const isSelected = yearIndex === selectedYear; + if (yearIndex === focusedYear) { + tabindex = "0"; + classes.push(CALENDAR_YEAR_FOCUSED_CLASS); + } + if (isSelected) { + classes.push(CALENDAR_YEAR_SELECTED_CLASS); + } + const btn = document.createElement("button"); + btn.setAttribute("type", "button"); + btn.setAttribute("tabindex", tabindex); + btn.setAttribute("class", classes.join(" ")); + btn.setAttribute("data-value", yearIndex); + btn.setAttribute("aria-selected", isSelected ? "true" : "false"); + if (isDisabled === true) { + btn.disabled = true; + } + btn.textContent = yearIndex; + years.push(btn); + yearIndex += 1; + } + const newCalendar = calendarEl.cloneNode(); + + // create the years calendar wrapper + const yearsCalendarWrapper = document.createElement("div"); + yearsCalendarWrapper.setAttribute("tabindex", "-1"); + yearsCalendarWrapper.setAttribute("class", CALENDAR_YEAR_PICKER_CLASS); + + // create table parent + const yearsTableParent = document.createElement("table"); + yearsTableParent.setAttribute("role", "presentation"); + yearsTableParent.setAttribute("class", CALENDAR_TABLE_CLASS); + + // create table body and table row + const yearsHTMLTableBody = document.createElement("tbody"); + const yearsHTMLTableBodyRow = document.createElement("tr"); + + // create previous button + const previousYearsBtn = document.createElement("button"); + previousYearsBtn.setAttribute("type", "button"); + previousYearsBtn.setAttribute("class", CALENDAR_PREVIOUS_YEAR_CHUNK_CLASS); + previousYearsBtn.setAttribute("aria-label", `Navigate back ${YEAR_CHUNK} years`); + if (prevYearChunkDisabled === true) { + previousYearsBtn.disabled = true; + } + previousYearsBtn.innerHTML = Sanitizer.escapeHTML` `; + + // create next button + const nextYearsBtn = document.createElement("button"); + nextYearsBtn.setAttribute("type", "button"); + nextYearsBtn.setAttribute("class", CALENDAR_NEXT_YEAR_CHUNK_CLASS); + nextYearsBtn.setAttribute("aria-label", `Navigate forward ${YEAR_CHUNK} years`); + if (nextYearChunkDisabled === true) { + nextYearsBtn.disabled = true; + } + nextYearsBtn.innerHTML = Sanitizer.escapeHTML` `; + + // create the actual years table + const yearsTable = document.createElement("table"); + yearsTable.setAttribute("class", CALENDAR_TABLE_CLASS); + yearsTable.setAttribute("role", "presentation"); + + // create the years child table + const yearsGrid = listToGridHtml(years, 3); + const yearsTableBody = createTableBody(yearsGrid); + + // append the grid to the years child table + yearsTable.insertAdjacentElement("beforeend", yearsTableBody); + + // create the prev button td and append the prev button + const yearsHTMLTableBodyDetailPrev = document.createElement("td"); + yearsHTMLTableBodyDetailPrev.insertAdjacentElement("beforeend", previousYearsBtn); + + // create the years td and append the years child table + const yearsHTMLTableBodyYearsDetail = document.createElement("td"); + yearsHTMLTableBodyYearsDetail.setAttribute("colspan", "3"); + yearsHTMLTableBodyYearsDetail.insertAdjacentElement("beforeend", yearsTable); + + // create the next button td and append the next button + const yearsHTMLTableBodyDetailNext = document.createElement("td"); + yearsHTMLTableBodyDetailNext.insertAdjacentElement("beforeend", nextYearsBtn); + + // append the three td to the years child table row + yearsHTMLTableBodyRow.insertAdjacentElement("beforeend", yearsHTMLTableBodyDetailPrev); + yearsHTMLTableBodyRow.insertAdjacentElement("beforeend", yearsHTMLTableBodyYearsDetail); + yearsHTMLTableBodyRow.insertAdjacentElement("beforeend", yearsHTMLTableBodyDetailNext); + + // append the table row to the years child table body + yearsHTMLTableBody.insertAdjacentElement("beforeend", yearsHTMLTableBodyRow); + + // append the years table body to the years parent table + yearsTableParent.insertAdjacentElement("beforeend", yearsHTMLTableBody); + + // append the parent table to the calendar wrapper + yearsCalendarWrapper.insertAdjacentElement("beforeend", yearsTableParent); + + // append the years calender to the new calendar + newCalendar.insertAdjacentElement("beforeend", yearsCalendarWrapper); + + // replace calendar + calendarEl.parentNode.replaceChild(newCalendar, calendarEl); + statusEl.textContent = Sanitizer.escapeHTML`Showing years ${yearToChunk} to ${yearToChunk + YEAR_CHUNK - 1}. Select a year.`; + return newCalendar; +}; + +/** + * Navigate back by years and display the year selection screen. + * + * @param {HTMLButtonElement} el An element within the date picker component + */ +const displayPreviousYearChunk = el => { + if (el.disabled) return; + const { + calendarEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(el); + const yearEl = calendarEl.querySelector(CALENDAR_YEAR_FOCUSED); + const selectedYear = parseInt(yearEl.textContent, 10); + let adjustedYear = selectedYear - YEAR_CHUNK; + adjustedYear = Math.max(0, adjustedYear); + const date = setYear(calendarDate, adjustedYear); + const cappedDate = keepDateBetweenMinAndMax(date, minDate, maxDate); + const newCalendar = displayYearSelection(calendarEl, cappedDate.getFullYear()); + let nextToFocus = newCalendar.querySelector(CALENDAR_PREVIOUS_YEAR_CHUNK); + if (nextToFocus.disabled) { + nextToFocus = newCalendar.querySelector(CALENDAR_YEAR_PICKER); + } + nextToFocus.focus(); +}; + +/** + * Navigate forward by years and display the year selection screen. + * + * @param {HTMLButtonElement} el An element within the date picker component + */ +const displayNextYearChunk = el => { + if (el.disabled) return; + const { + calendarEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(el); + const yearEl = calendarEl.querySelector(CALENDAR_YEAR_FOCUSED); + const selectedYear = parseInt(yearEl.textContent, 10); + let adjustedYear = selectedYear + YEAR_CHUNK; + adjustedYear = Math.max(0, adjustedYear); + const date = setYear(calendarDate, adjustedYear); + const cappedDate = keepDateBetweenMinAndMax(date, minDate, maxDate); + const newCalendar = displayYearSelection(calendarEl, cappedDate.getFullYear()); + let nextToFocus = newCalendar.querySelector(CALENDAR_NEXT_YEAR_CHUNK); + if (nextToFocus.disabled) { + nextToFocus = newCalendar.querySelector(CALENDAR_YEAR_PICKER); + } + nextToFocus.focus(); +}; + +/** + * Select a year in the date picker component. + * + * @param {HTMLButtonElement} yearEl A year element within the date picker component + */ +const selectYear = yearEl => { + if (yearEl.disabled) return; + const { + calendarEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(yearEl); + const selectedYear = parseInt(yearEl.innerHTML, 10); + let date = setYear(calendarDate, selectedYear); + date = keepDateBetweenMinAndMax(date, minDate, maxDate); + const newCalendar = renderCalendar(calendarEl, date); + newCalendar.querySelector(CALENDAR_DATE_FOCUSED).focus(); +}; + +// #endregion Calendar - Year Selection View + +// #region Calendar Event Handling + +/** + * Hide the calendar. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleEscapeFromCalendar = event => { + const { + datePickerEl, + externalInputEl + } = getDatePickerContext(event.target); + hideCalendar(datePickerEl); + externalInputEl.focus(); + event.preventDefault(); +}; + +// #endregion Calendar Event Handling + +// #region Calendar Date Event Handling + +/** + * Adjust the date and display the calendar if needed. + * + * @param {function} adjustDateFn function that returns the adjusted date + */ +const adjustCalendar = adjustDateFn => event => { + const { + calendarEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(event.target); + const date = adjustDateFn(calendarDate); + const cappedDate = keepDateBetweenMinAndMax(date, minDate, maxDate); + if (!isSameDay(calendarDate, cappedDate)) { + const newCalendar = renderCalendar(calendarEl, cappedDate); + newCalendar.querySelector(CALENDAR_DATE_FOCUSED).focus(); + } + event.preventDefault(); +}; + +/** + * Navigate back one week and display the calendar. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleUpFromDate = adjustCalendar(date => subWeeks(date, 1)); + +/** + * Navigate forward one week and display the calendar. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleDownFromDate = adjustCalendar(date => addWeeks(date, 1)); + +/** + * Navigate back one day and display the calendar. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleLeftFromDate = adjustCalendar(date => subDays(date, 1)); + +/** + * Navigate forward one day and display the calendar. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleRightFromDate = adjustCalendar(date => addDays(date, 1)); + +/** + * Navigate to the start of the week and display the calendar. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleHomeFromDate = adjustCalendar(date => startOfWeek(date)); + +/** + * Navigate to the end of the week and display the calendar. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleEndFromDate = adjustCalendar(date => endOfWeek(date)); + +/** + * Navigate forward one month and display the calendar. + * + * @param {KeyboardEvent} event the keydown event + */ +const handlePageDownFromDate = adjustCalendar(date => addMonths(date, 1)); + +/** + * Navigate back one month and display the calendar. + * + * @param {KeyboardEvent} event the keydown event + */ +const handlePageUpFromDate = adjustCalendar(date => subMonths(date, 1)); + +/** + * Navigate forward one year and display the calendar. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleShiftPageDownFromDate = adjustCalendar(date => addYears(date, 1)); + +/** + * Navigate back one year and display the calendar. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleShiftPageUpFromDate = adjustCalendar(date => subYears(date, 1)); + +/** + * display the calendar for the mouseover date. + * + * @param {MouseEvent} event The mouseover event + * @param {HTMLButtonElement} dateEl A date element within the date picker component + */ +const handleMouseoverFromDate = dateEl => { + if (dateEl.disabled) return; + const calendarEl = dateEl.closest(DATE_PICKER_CALENDAR); + const currentCalendarDate = calendarEl.dataset.value; + const hoverDate = dateEl.dataset.value; + if (hoverDate === currentCalendarDate) return; + const dateToDisplay = parseDateString(hoverDate); + const newCalendar = renderCalendar(calendarEl, dateToDisplay); + newCalendar.querySelector(CALENDAR_DATE_FOCUSED).focus(); +}; + +// #endregion Calendar Date Event Handling + +// #region Calendar Month Event Handling + +/** + * Adjust the month and display the month selection screen if needed. + * + * @param {function} adjustMonthFn function that returns the adjusted month + */ +const adjustMonthSelectionScreen = adjustMonthFn => event => { + const monthEl = event.target; + const selectedMonth = parseInt(monthEl.dataset.value, 10); + const { + calendarEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(monthEl); + const currentDate = setMonth(calendarDate, selectedMonth); + let adjustedMonth = adjustMonthFn(selectedMonth); + adjustedMonth = Math.max(0, Math.min(11, adjustedMonth)); + const date = setMonth(calendarDate, adjustedMonth); + const cappedDate = keepDateBetweenMinAndMax(date, minDate, maxDate); + if (!isSameMonth(currentDate, cappedDate)) { + const newCalendar = displayMonthSelection(calendarEl, cappedDate.getMonth()); + newCalendar.querySelector(CALENDAR_MONTH_FOCUSED).focus(); + } + event.preventDefault(); +}; + +/** + * Navigate back three months and display the month selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleUpFromMonth = adjustMonthSelectionScreen(month => month - 3); + +/** + * Navigate forward three months and display the month selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleDownFromMonth = adjustMonthSelectionScreen(month => month + 3); + +/** + * Navigate back one month and display the month selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleLeftFromMonth = adjustMonthSelectionScreen(month => month - 1); + +/** + * Navigate forward one month and display the month selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleRightFromMonth = adjustMonthSelectionScreen(month => month + 1); + +/** + * Navigate to the start of the row of months and display the month selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleHomeFromMonth = adjustMonthSelectionScreen(month => month - month % 3); + +/** + * Navigate to the end of the row of months and display the month selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleEndFromMonth = adjustMonthSelectionScreen(month => month + 2 - month % 3); + +/** + * Navigate to the last month (December) and display the month selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handlePageDownFromMonth = adjustMonthSelectionScreen(() => 11); + +/** + * Navigate to the first month (January) and display the month selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handlePageUpFromMonth = adjustMonthSelectionScreen(() => 0); + +/** + * update the focus on a month when the mouse moves. + * + * @param {MouseEvent} event The mouseover event + * @param {HTMLButtonElement} monthEl A month element within the date picker component + */ +const handleMouseoverFromMonth = monthEl => { + if (monthEl.disabled) return; + if (monthEl.classList.contains(CALENDAR_MONTH_FOCUSED_CLASS)) return; + const focusMonth = parseInt(monthEl.dataset.value, 10); + const newCalendar = displayMonthSelection(monthEl, focusMonth); + newCalendar.querySelector(CALENDAR_MONTH_FOCUSED).focus(); +}; + +// #endregion Calendar Month Event Handling + +// #region Calendar Year Event Handling + +/** + * Adjust the year and display the year selection screen if needed. + * + * @param {function} adjustYearFn function that returns the adjusted year + */ +const adjustYearSelectionScreen = adjustYearFn => event => { + const yearEl = event.target; + const selectedYear = parseInt(yearEl.dataset.value, 10); + const { + calendarEl, + calendarDate, + minDate, + maxDate + } = getDatePickerContext(yearEl); + const currentDate = setYear(calendarDate, selectedYear); + let adjustedYear = adjustYearFn(selectedYear); + adjustedYear = Math.max(0, adjustedYear); + const date = setYear(calendarDate, adjustedYear); + const cappedDate = keepDateBetweenMinAndMax(date, minDate, maxDate); + if (!isSameYear(currentDate, cappedDate)) { + const newCalendar = displayYearSelection(calendarEl, cappedDate.getFullYear()); + newCalendar.querySelector(CALENDAR_YEAR_FOCUSED).focus(); + } + event.preventDefault(); +}; + +/** + * Navigate back three years and display the year selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleUpFromYear = adjustYearSelectionScreen(year => year - 3); + +/** + * Navigate forward three years and display the year selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleDownFromYear = adjustYearSelectionScreen(year => year + 3); + +/** + * Navigate back one year and display the year selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleLeftFromYear = adjustYearSelectionScreen(year => year - 1); + +/** + * Navigate forward one year and display the year selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleRightFromYear = adjustYearSelectionScreen(year => year + 1); + +/** + * Navigate to the start of the row of years and display the year selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleHomeFromYear = adjustYearSelectionScreen(year => year - year % 3); + +/** + * Navigate to the end of the row of years and display the year selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handleEndFromYear = adjustYearSelectionScreen(year => year + 2 - year % 3); + +/** + * Navigate to back 12 years and display the year selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handlePageUpFromYear = adjustYearSelectionScreen(year => year - YEAR_CHUNK); + +/** + * Navigate forward 12 years and display the year selection screen. + * + * @param {KeyboardEvent} event the keydown event + */ +const handlePageDownFromYear = adjustYearSelectionScreen(year => year + YEAR_CHUNK); + +/** + * update the focus on a year when the mouse moves. + * + * @param {MouseEvent} event The mouseover event + * @param {HTMLButtonElement} dateEl A year element within the date picker component + */ +const handleMouseoverFromYear = yearEl => { + if (yearEl.disabled) return; + if (yearEl.classList.contains(CALENDAR_YEAR_FOCUSED_CLASS)) return; + const focusYear = parseInt(yearEl.dataset.value, 10); + const newCalendar = displayYearSelection(yearEl, focusYear); + newCalendar.querySelector(CALENDAR_YEAR_FOCUSED).focus(); +}; + +// #endregion Calendar Year Event Handling + +// #region Focus Handling Event Handling + +const tabHandler = focusable => { + const getFocusableContext = el => { + const { + calendarEl + } = getDatePickerContext(el); + const focusableElements = select(focusable, calendarEl); + const firstTabIndex = 0; + const lastTabIndex = focusableElements.length - 1; + const firstTabStop = focusableElements[firstTabIndex]; + const lastTabStop = focusableElements[lastTabIndex]; + const focusIndex = focusableElements.indexOf(activeElement()); + const isLastTab = focusIndex === lastTabIndex; + const isFirstTab = focusIndex === firstTabIndex; + const isNotFound = focusIndex === -1; + return { + focusableElements, + isNotFound, + firstTabStop, + isFirstTab, + lastTabStop, + isLastTab + }; + }; + return { + tabAhead(event) { + const { + firstTabStop, + isLastTab, + isNotFound + } = getFocusableContext(event.target); + if (isLastTab || isNotFound) { + event.preventDefault(); + firstTabStop.focus(); + } + }, + tabBack(event) { + const { + lastTabStop, + isFirstTab, + isNotFound + } = getFocusableContext(event.target); + if (isFirstTab || isNotFound) { + event.preventDefault(); + lastTabStop.focus(); + } + } + }; +}; +const datePickerTabEventHandler = tabHandler(DATE_PICKER_FOCUSABLE); +const monthPickerTabEventHandler = tabHandler(MONTH_PICKER_FOCUSABLE); +const yearPickerTabEventHandler = tabHandler(YEAR_PICKER_FOCUSABLE); + +// #endregion Focus Handling Event Handling + +// #region Date Picker Event Delegation Registration / Component + +const datePickerEvents = { + [CLICK]: { + [DATE_PICKER_BUTTON]() { + toggleCalendar(this); + }, + [CALENDAR_DATE]() { + selectDate(this); + }, + [CALENDAR_MONTH]() { + selectMonth(this); + }, + [CALENDAR_YEAR]() { + selectYear(this); + }, + [CALENDAR_PREVIOUS_MONTH]() { + displayPreviousMonth(this); + }, + [CALENDAR_NEXT_MONTH]() { + displayNextMonth(this); + }, + [CALENDAR_PREVIOUS_YEAR]() { + displayPreviousYear(this); + }, + [CALENDAR_NEXT_YEAR]() { + displayNextYear(this); + }, + [CALENDAR_PREVIOUS_YEAR_CHUNK]() { + displayPreviousYearChunk(this); + }, + [CALENDAR_NEXT_YEAR_CHUNK]() { + displayNextYearChunk(this); + }, + [CALENDAR_MONTH_SELECTION]() { + const newCalendar = displayMonthSelection(this); + newCalendar.querySelector(CALENDAR_MONTH_FOCUSED).focus(); + }, + [CALENDAR_YEAR_SELECTION]() { + const newCalendar = displayYearSelection(this); + newCalendar.querySelector(CALENDAR_YEAR_FOCUSED).focus(); + } + }, + keyup: { + [DATE_PICKER_CALENDAR](event) { + const keydown = this.dataset.keydownKeyCode; + if (`${event.keyCode}` !== keydown) { + event.preventDefault(); + } + } + }, + keydown: { + [DATE_PICKER_EXTERNAL_INPUT](event) { + if (event.keyCode === ENTER_KEYCODE) { + validateDateInput(this); + } + }, + [CALENDAR_DATE]: keymap({ + Up: handleUpFromDate, + ArrowUp: handleUpFromDate, + Down: handleDownFromDate, + ArrowDown: handleDownFromDate, + Left: handleLeftFromDate, + ArrowLeft: handleLeftFromDate, + Right: handleRightFromDate, + ArrowRight: handleRightFromDate, + Home: handleHomeFromDate, + End: handleEndFromDate, + PageDown: handlePageDownFromDate, + PageUp: handlePageUpFromDate, + "Shift+PageDown": handleShiftPageDownFromDate, + "Shift+PageUp": handleShiftPageUpFromDate, + Tab: datePickerTabEventHandler.tabAhead + }), + [CALENDAR_DATE_PICKER]: keymap({ + Tab: datePickerTabEventHandler.tabAhead, + "Shift+Tab": datePickerTabEventHandler.tabBack + }), + [CALENDAR_MONTH]: keymap({ + Up: handleUpFromMonth, + ArrowUp: handleUpFromMonth, + Down: handleDownFromMonth, + ArrowDown: handleDownFromMonth, + Left: handleLeftFromMonth, + ArrowLeft: handleLeftFromMonth, + Right: handleRightFromMonth, + ArrowRight: handleRightFromMonth, + Home: handleHomeFromMonth, + End: handleEndFromMonth, + PageDown: handlePageDownFromMonth, + PageUp: handlePageUpFromMonth + }), + [CALENDAR_MONTH_PICKER]: keymap({ + Tab: monthPickerTabEventHandler.tabAhead, + "Shift+Tab": monthPickerTabEventHandler.tabBack + }), + [CALENDAR_YEAR]: keymap({ + Up: handleUpFromYear, + ArrowUp: handleUpFromYear, + Down: handleDownFromYear, + ArrowDown: handleDownFromYear, + Left: handleLeftFromYear, + ArrowLeft: handleLeftFromYear, + Right: handleRightFromYear, + ArrowRight: handleRightFromYear, + Home: handleHomeFromYear, + End: handleEndFromYear, + PageDown: handlePageDownFromYear, + PageUp: handlePageUpFromYear + }), + [CALENDAR_YEAR_PICKER]: keymap({ + Tab: yearPickerTabEventHandler.tabAhead, + "Shift+Tab": yearPickerTabEventHandler.tabBack + }), + [DATE_PICKER_CALENDAR](event) { + this.dataset.keydownKeyCode = event.keyCode; + }, + [DATE_PICKER](event) { + const keyMap = keymap({ + Escape: handleEscapeFromCalendar + }); + keyMap(event); + } + }, + focusout: { + [DATE_PICKER_EXTERNAL_INPUT]() { + validateDateInput(this); + }, + [DATE_PICKER](event) { + if (!this.contains(event.relatedTarget)) { + hideCalendar(this); + } + } + }, + input: { + [DATE_PICKER_EXTERNAL_INPUT]() { + reconcileInputValues(this); + updateCalendarIfVisible(this); + } + } +}; +if (!isIosDevice()) { + datePickerEvents.mouseover = { + [CALENDAR_DATE_CURRENT_MONTH]() { + handleMouseoverFromDate(this); + }, + [CALENDAR_MONTH]() { + handleMouseoverFromMonth(this); + }, + [CALENDAR_YEAR]() { + handleMouseoverFromYear(this); + } + }; +} +const datePicker = behavior(datePickerEvents, { + init(root) { + selectOrMatches(DATE_PICKER, root).forEach(datePickerEl => { + enhanceDatePicker(datePickerEl); + }); + }, + getDatePickerContext, + disable, + enable, + isDateInputInvalid, + setCalendarValue, + validateDateInput, + renderCalendar, + updateCalendarIfVisible +}); + +// #endregion Date Picker Event Delegation Registration / Component + +module.exports = datePicker; + +},{"../../uswds-core/src/js/config":35,"../../uswds-core/src/js/events":36,"../../uswds-core/src/js/utils/active-element":44,"../../uswds-core/src/js/utils/behavior":45,"../../uswds-core/src/js/utils/is-ios-device":49,"../../uswds-core/src/js/utils/sanitizer":50,"../../uswds-core/src/js/utils/select":53,"../../uswds-core/src/js/utils/select-or-matches":52,"receptor/keymap":11}],20:[function(require,module,exports){ +"use strict"; + +const behavior = require("../../uswds-core/src/js/utils/behavior"); +const select = require("../../uswds-core/src/js/utils/select"); +const selectOrMatches = require("../../uswds-core/src/js/utils/select-or-matches"); +const { + prefix: PREFIX +} = require("../../uswds-core/src/js/config"); +const { + getDatePickerContext, + isDateInputInvalid, + updateCalendarIfVisible +} = require("../../usa-date-picker/src/index"); +const DATE_PICKER_CLASS = `${PREFIX}-date-picker`; +const DATE_RANGE_PICKER_CLASS = `${PREFIX}-date-range-picker`; +const DATE_RANGE_PICKER_RANGE_START_CLASS = `${DATE_RANGE_PICKER_CLASS}__range-start`; +const DATE_RANGE_PICKER_RANGE_END_CLASS = `${DATE_RANGE_PICKER_CLASS}__range-end`; +const DATE_PICKER = `.${DATE_PICKER_CLASS}`; +const DATE_RANGE_PICKER = `.${DATE_RANGE_PICKER_CLASS}`; +const DATE_RANGE_PICKER_RANGE_START = `.${DATE_RANGE_PICKER_RANGE_START_CLASS}`; +const DATE_RANGE_PICKER_RANGE_END = `.${DATE_RANGE_PICKER_RANGE_END_CLASS}`; +const DEFAULT_MIN_DATE = "0000-01-01"; + +/** + * The properties and elements within the date range picker. + * @typedef {Object} DateRangePickerContext + * @property {HTMLElement} dateRangePickerEl + * @property {HTMLElement} rangeStartEl + * @property {HTMLElement} rangeEndEl + */ + +/** + * Get an object of the properties and elements belonging directly to the given + * date picker component. + * + * @param {HTMLElement} el the element within the date picker + * @returns {DateRangePickerContext} elements + */ +const getDateRangePickerContext = el => { + const dateRangePickerEl = el.closest(DATE_RANGE_PICKER); + if (!dateRangePickerEl) { + throw new Error(`Element is missing outer ${DATE_RANGE_PICKER}`); + } + const rangeStartEl = dateRangePickerEl.querySelector(DATE_RANGE_PICKER_RANGE_START); + const rangeEndEl = dateRangePickerEl.querySelector(DATE_RANGE_PICKER_RANGE_END); + return { + dateRangePickerEl, + rangeStartEl, + rangeEndEl + }; +}; + +/** + * handle update from range start date picker + * + * @param {HTMLElement} el an element within the date range picker + */ +const handleRangeStartUpdate = el => { + const { + dateRangePickerEl, + rangeStartEl, + rangeEndEl + } = getDateRangePickerContext(el); + const { + internalInputEl + } = getDatePickerContext(rangeStartEl); + const updatedDate = internalInputEl.value; + if (updatedDate && !isDateInputInvalid(internalInputEl)) { + rangeEndEl.dataset.minDate = updatedDate; + rangeEndEl.dataset.rangeDate = updatedDate; + rangeEndEl.dataset.defaultDate = updatedDate; + } else { + rangeEndEl.dataset.minDate = dateRangePickerEl.dataset.minDate || ""; + rangeEndEl.dataset.rangeDate = ""; + rangeEndEl.dataset.defaultDate = ""; + } + updateCalendarIfVisible(rangeEndEl); +}; + +/** + * handle update from range start date picker + * + * @param {HTMLElement} el an element within the date range picker + */ +const handleRangeEndUpdate = el => { + const { + dateRangePickerEl, + rangeStartEl, + rangeEndEl + } = getDateRangePickerContext(el); + const { + internalInputEl + } = getDatePickerContext(rangeEndEl); + const updatedDate = internalInputEl.value; + if (updatedDate && !isDateInputInvalid(internalInputEl)) { + rangeStartEl.dataset.maxDate = updatedDate; + rangeStartEl.dataset.rangeDate = updatedDate; + rangeStartEl.dataset.defaultDate = updatedDate; + } else { + rangeStartEl.dataset.maxDate = dateRangePickerEl.dataset.maxDate || ""; + rangeStartEl.dataset.rangeDate = ""; + rangeStartEl.dataset.defaultDate = ""; + } + updateCalendarIfVisible(rangeStartEl); +}; + +/** + * Enhance an input with the date picker elements + * + * @param {HTMLElement} el The initial wrapping element of the date range picker component + */ +const enhanceDateRangePicker = el => { + const dateRangePickerEl = el.closest(DATE_RANGE_PICKER); + const [rangeStart, rangeEnd] = select(DATE_PICKER, dateRangePickerEl); + if (!rangeStart) { + throw new Error(`${DATE_RANGE_PICKER} is missing inner two '${DATE_PICKER}' elements`); + } + if (!rangeEnd) { + throw new Error(`${DATE_RANGE_PICKER} is missing second '${DATE_PICKER}' element`); + } + rangeStart.classList.add(DATE_RANGE_PICKER_RANGE_START_CLASS); + rangeEnd.classList.add(DATE_RANGE_PICKER_RANGE_END_CLASS); + if (!dateRangePickerEl.dataset.minDate) { + dateRangePickerEl.dataset.minDate = DEFAULT_MIN_DATE; + } + const { + minDate + } = dateRangePickerEl.dataset; + rangeStart.dataset.minDate = minDate; + rangeEnd.dataset.minDate = minDate; + const { + maxDate + } = dateRangePickerEl.dataset; + if (maxDate) { + rangeStart.dataset.maxDate = maxDate; + rangeEnd.dataset.maxDate = maxDate; + } + handleRangeStartUpdate(dateRangePickerEl); + handleRangeEndUpdate(dateRangePickerEl); +}; +const dateRangePicker = behavior({ + "input change": { + [DATE_RANGE_PICKER_RANGE_START]() { + handleRangeStartUpdate(this); + }, + [DATE_RANGE_PICKER_RANGE_END]() { + handleRangeEndUpdate(this); + } + } +}, { + init(root) { + selectOrMatches(DATE_RANGE_PICKER, root).forEach(dateRangePickerEl => { + enhanceDateRangePicker(dateRangePickerEl); + }); + } +}); +module.exports = dateRangePicker; + +},{"../../usa-date-picker/src/index":19,"../../uswds-core/src/js/config":35,"../../uswds-core/src/js/utils/behavior":45,"../../uswds-core/src/js/utils/select":53,"../../uswds-core/src/js/utils/select-or-matches":52}],21:[function(require,module,exports){ +"use strict"; + +const selectOrMatches = require("../../uswds-core/src/js/utils/select-or-matches"); +const behavior = require("../../uswds-core/src/js/utils/behavior"); +const Sanitizer = require("../../uswds-core/src/js/utils/sanitizer"); +const { + prefix: PREFIX +} = require("../../uswds-core/src/js/config"); +const DROPZONE_CLASS = `${PREFIX}-file-input`; +const DROPZONE = `.${DROPZONE_CLASS}`; +const INPUT_CLASS = `${PREFIX}-file-input__input`; +const TARGET_CLASS = `${PREFIX}-file-input__target`; +const INPUT = `.${INPUT_CLASS}`; +const BOX_CLASS = `${PREFIX}-file-input__box`; +const INSTRUCTIONS_CLASS = `${PREFIX}-file-input__instructions`; +const PREVIEW_CLASS = `${PREFIX}-file-input__preview`; +const PREVIEW_HEADING_CLASS = `${PREFIX}-file-input__preview-heading`; +const DISABLED_CLASS = `${PREFIX}-file-input--disabled`; +const CHOOSE_CLASS = `${PREFIX}-file-input__choose`; +const ACCEPTED_FILE_MESSAGE_CLASS = `${PREFIX}-file-input__accepted-files-message`; +const DRAG_TEXT_CLASS = `${PREFIX}-file-input__drag-text`; +const DRAG_CLASS = `${PREFIX}-file-input--drag`; +const LOADING_CLASS = "is-loading"; +const HIDDEN_CLASS = "display-none"; +const INVALID_FILE_CLASS = "has-invalid-file"; +const GENERIC_PREVIEW_CLASS_NAME = `${PREFIX}-file-input__preview-image`; +const GENERIC_PREVIEW_CLASS = `${GENERIC_PREVIEW_CLASS_NAME}--generic`; +const PDF_PREVIEW_CLASS = `${GENERIC_PREVIEW_CLASS_NAME}--pdf`; +const WORD_PREVIEW_CLASS = `${GENERIC_PREVIEW_CLASS_NAME}--word`; +const VIDEO_PREVIEW_CLASS = `${GENERIC_PREVIEW_CLASS_NAME}--video`; +const EXCEL_PREVIEW_CLASS = `${GENERIC_PREVIEW_CLASS_NAME}--excel`; +const SPACER_GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; +let TYPE_IS_VALID = Boolean(true); // logic gate for change listener + +/** + * The properties and elements within the file input. + * @typedef {Object} FileInputContext + * @property {HTMLDivElement} dropZoneEl + * @property {HTMLInputElement} inputEl + */ + +/** + * Get an object of the properties and elements belonging directly to the given + * file input component. + * + * @param {HTMLElement} el the element within the file input + * @returns {FileInputContext} elements + */ +const getFileInputContext = el => { + const dropZoneEl = el.closest(DROPZONE); + if (!dropZoneEl) { + throw new Error(`Element is missing outer ${DROPZONE}`); + } + const inputEl = dropZoneEl.querySelector(INPUT); + return { + dropZoneEl, + inputEl + }; +}; + +/** + * Disable the file input component + * + * @param {HTMLElement} el An element within the file input component + */ +const disable = el => { + const { + dropZoneEl, + inputEl + } = getFileInputContext(el); + inputEl.disabled = true; + dropZoneEl.classList.add(DISABLED_CLASS); + dropZoneEl.setAttribute("aria-disabled", "true"); +}; + +/** + * Enable the file input component + * + * @param {HTMLElement} el An element within the file input component + */ +const enable = el => { + const { + dropZoneEl, + inputEl + } = getFileInputContext(el); + inputEl.disabled = false; + dropZoneEl.classList.remove(DISABLED_CLASS); + dropZoneEl.removeAttribute("aria-disabled"); +}; + +/** + * + * @param {String} s special characters + * @returns {String} replaces specified values + */ +const replaceName = s => { + const c = s.charCodeAt(0); + if (c === 32) return "-"; + if (c >= 65 && c <= 90) return `img_${s.toLowerCase()}`; + return `__${("000", c.toString(16)).slice(-4)}`; +}; + +/** + * Creates an ID name for each file that strips all invalid characters. + * @param {String} name - name of the file added to file input (searchvalue) + * @returns {String} same characters as the name with invalid chars removed (newvalue) + */ +const makeSafeForID = name => name.replace(/[^a-z0-9]/g, replaceName); + +// Takes a generated safe ID and creates a unique ID. +const createUniqueID = name => `${name}-${Math.floor(Date.now().toString() / 1000)}`; + +/** + * Builds full file input component + * @param {HTMLElement} fileInputEl - original file input on page + * @returns {HTMLElement|HTMLElement} - Instructions, target area div + */ +const buildFileInput = fileInputEl => { + const acceptsMultiple = fileInputEl.hasAttribute("multiple"); + const fileInputParent = document.createElement("div"); + const dropTarget = document.createElement("div"); + const box = document.createElement("div"); + const instructions = document.createElement("div"); + const disabled = fileInputEl.hasAttribute("disabled"); + let defaultAriaLabel; + + // Adds class names and other attributes + fileInputEl.classList.remove(DROPZONE_CLASS); + fileInputEl.classList.add(INPUT_CLASS); + fileInputParent.classList.add(DROPZONE_CLASS); + box.classList.add(BOX_CLASS); + instructions.classList.add(INSTRUCTIONS_CLASS); + instructions.setAttribute("aria-hidden", "true"); + dropTarget.classList.add(TARGET_CLASS); + // Encourage screenreader to read out aria changes immediately following upload status change + fileInputEl.setAttribute("aria-live", "polite"); + + // Adds child elements to the DOM + fileInputEl.parentNode.insertBefore(dropTarget, fileInputEl); + fileInputEl.parentNode.insertBefore(fileInputParent, dropTarget); + dropTarget.appendChild(fileInputEl); + fileInputParent.appendChild(dropTarget); + fileInputEl.parentNode.insertBefore(instructions, fileInputEl); + fileInputEl.parentNode.insertBefore(box, fileInputEl); + + // Disabled styling + if (disabled) { + disable(fileInputEl); + } + + // Sets instruction test and aria-label based on whether or not multiple files are accepted + if (acceptsMultiple) { + defaultAriaLabel = "No files selected"; + instructions.innerHTML = Sanitizer.escapeHTML`Drag files here or choose from folder`; + fileInputEl.setAttribute("aria-label", defaultAriaLabel); + fileInputEl.setAttribute("data-default-aria-label", defaultAriaLabel); + } else { + defaultAriaLabel = "No file selected"; + instructions.innerHTML = Sanitizer.escapeHTML`Drag file here or choose from folder`; + fileInputEl.setAttribute("aria-label", defaultAriaLabel); + fileInputEl.setAttribute("data-default-aria-label", defaultAriaLabel); + } + + // IE11 and Edge do not support drop files on file inputs, so we've removed text that indicates that + if (/rv:11.0/i.test(navigator.userAgent) || /Edge\/\d./i.test(navigator.userAgent)) { + fileInputParent.querySelector(`.${DRAG_TEXT_CLASS}`).outerHTML = ""; + } + return { + instructions, + dropTarget + }; +}; + +/** + * Removes image previews, we want to start with a clean list every time files are added to the file input + * @param {HTMLElement} dropTarget - target area div that encases the input + * @param {HTMLElement} instructions - text to inform users to drag or select files + */ +const removeOldPreviews = (dropTarget, instructions, inputAriaLabel) => { + const filePreviews = dropTarget.querySelectorAll(`.${PREVIEW_CLASS}`); + const fileInputElement = dropTarget.querySelector(INPUT); + const currentPreviewHeading = dropTarget.querySelector(`.${PREVIEW_HEADING_CLASS}`); + const currentErrorMessage = dropTarget.querySelector(`.${ACCEPTED_FILE_MESSAGE_CLASS}`); + + /** + * finds the parent of the passed node and removes the child + * @param {HTMLElement} node + */ + const removeImages = node => { + node.parentNode.removeChild(node); + }; + + // Remove the heading above the previews + if (currentPreviewHeading) { + currentPreviewHeading.outerHTML = ""; + } + + // Remove existing error messages + if (currentErrorMessage) { + currentErrorMessage.outerHTML = ""; + dropTarget.classList.remove(INVALID_FILE_CLASS); + } + + // Get rid of existing previews if they exist, show instructions + if (filePreviews !== null) { + if (instructions) { + instructions.classList.remove(HIDDEN_CLASS); + } + fileInputElement.setAttribute("aria-label", inputAriaLabel); + Array.prototype.forEach.call(filePreviews, removeImages); + } +}; + +/** + * When new files are applied to file input, this function generates previews + * and removes old ones. + * @param {event} e + * @param {HTMLElement} fileInputEl - file input element + * @param {HTMLElement} instructions - text to inform users to drag or select files + * @param {HTMLElement} dropTarget - target area div that encases the input + */ + +const handleChange = (e, fileInputEl, instructions, dropTarget) => { + const fileNames = e.target.files; + const filePreviewsHeading = document.createElement("div"); + const inputAriaLabel = fileInputEl.dataset.defaultAriaLabel; + const fileStore = []; + + // First, get rid of existing previews + removeOldPreviews(dropTarget, instructions, inputAriaLabel); + + // Then, iterate through files list and: + // 1. Add selected file list names to aria-label + // 2. Create previews + for (let i = 0; i < fileNames.length; i += 1) { + const reader = new FileReader(); + const fileName = fileNames[i].name; + + // Push updated file names into the store array + fileStore.push(fileName); + + // read out the store array via aria-label, wording options vary based on file count + if (i === 0) { + fileInputEl.setAttribute("aria-label", `You have selected the file: ${fileName}`); + } else if (i >= 1) { + fileInputEl.setAttribute("aria-label", `You have selected ${fileNames.length} files: ${fileStore.join(", ")}`); + } + + // Starts with a loading image while preview is created + reader.onloadstart = function createLoadingImage() { + const imageId = createUniqueID(makeSafeForID(fileName)); + instructions.insertAdjacentHTML("afterend", Sanitizer.escapeHTML` -
{% block after_form_content %}{% endblock %} diff --git a/src/registrar/templates/domain_request_review.html b/src/registrar/templates/domain_request_review.html index 2960e40c7..1f21683a5 100644 --- a/src/registrar/templates/domain_request_review.html +++ b/src/registrar/templates/domain_request_review.html @@ -25,11 +25,11 @@ {% if step == Step.ORGANIZATION_TYPE %} {% namespaced_url 'domain-request' step as domain_request_url %} {% if domain_request.generic_org_type is not None %} - {% with title=form_titles|get_item:step value=domain_request.get_generic_org_type_display|default:"Incomplete"|safe %} + {% with title=form_titles|get_item:step value=domain_request.get_generic_org_type_display|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value="Incomplete"|safe %} + {% with title=form_titles|get_item:step value="Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -37,7 +37,7 @@ {% if step == Step.TRIBAL_GOVERNMENT %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.tribe_name|default:"Incomplete"|safe %} + {% with title=form_titles|get_item:step value=domain_request.tribe_name|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% if domain_request.federally_recognized_tribe %}

Federally-recognized tribe

{% endif %} @@ -47,7 +47,7 @@ {% if step == Step.ORGANIZATION_FEDERAL %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.get_federal_type_display|default:"Incomplete"|safe %} + {% with title=form_titles|get_item:step value=domain_request.get_federal_type_display|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -66,7 +66,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url address='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value="Incomplete"|safe %} + {% with title=form_titles|get_item:step value="Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -74,7 +74,7 @@ {% if step == Step.ABOUT_YOUR_ORGANIZATION %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.about_your_organization|default:"Incomplete"|safe %} + {% with title=form_titles|get_item:step value=domain_request.about_your_organization|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -86,7 +86,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value="Incomplete"|safe %} + {% with title=form_titles|get_item:step value="Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -107,7 +107,7 @@ {% if step == Step.DOTGOV_DOMAIN %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete"|safe%} + {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete"|safe%} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} @@ -123,7 +123,7 @@ {% if step == Step.PURPOSE %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.purpose|default:"Incomplete"|safe %} + {% with title=form_titles|get_item:step value=domain_request.purpose|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -135,7 +135,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value="Incomplete"|safe %} + {% with title=form_titles|get_item:step value="Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -148,7 +148,7 @@ {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url contact='true' list='true' %} {% endwith %} {% else %} - {% with title=form_titles|get_item:step value=domain_request.no_other_contacts_rationale|default:"Incomplete"|safe %} + {% with title=form_titles|get_item:step value=domain_request.no_other_contacts_rationale|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title value=value heading_level=heading_level editable=True edit_link=domain_request_url %} {% endwith %} {% endif %} @@ -157,7 +157,7 @@ {% if step == Step.ADDITIONAL_DETAILS %} {% namespaced_url 'domain-request' step as domain_request_url %} - {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete"|safe %} + {% with title=form_titles|get_item:step value=domain_request.requested_domain.name|default:"Incomplete"|safe %} {% include "includes/summary_item.html" with title=title sub_header_text='CISA regional representative' value=domain_request.cisa_representative_email heading_level=heading_level editable=True edit_link=domain_request_url custom_text_for_value_none='No' %} {% endwith %} diff --git a/src/registrar/templates/includes/modal.html b/src/registrar/templates/includes/modal.html index 24b581516..3c82aac5b 100644 --- a/src/registrar/templates/includes/modal.html +++ b/src/registrar/templates/includes/modal.html @@ -39,7 +39,7 @@ Cancel - {% elif review_form_is_complete %} + {% elif not resets_domain_request or review_form_is_complete %}
{% block after_form_content %}{% endblock %} diff --git a/src/registrar/templates/includes/modal.html b/src/registrar/templates/includes/modal.html index 3c82aac5b..cb99fa321 100644 --- a/src/registrar/templates/includes/modal.html +++ b/src/registrar/templates/includes/modal.html @@ -39,7 +39,7 @@ Cancel - {% elif not resets_domain_request or review_form_is_complete %} + {% elif not is_domain_request_form or review_form_is_complete %} + {% endblock profile_form %} From fa1fcba49aaec2f0620d0de09e22e92abdc30649 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 28 May 2024 11:50:44 -0700 Subject: [PATCH 107/214] Add fixed iselection and the whole additional details unit tests --- src/registrar/models/domain_request.py | 12 +- src/registrar/tests/test_models.py | 275 ++++++++++++++++--------- 2 files changed, 181 insertions(+), 106 deletions(-) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index e3bd1c8f9..ed98433fa 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -970,10 +970,10 @@ class DomainRequest(TimeStampedModel): def _is_organization_name_and_address_complete(self): return not ( self.organization_name is None - or self.address_line1 is None - or self.city is None - or self.state_territory is None - or self.zipcode is None + and self.address_line1 is None + and self.city is None + and self.state_territory is None + and self.zipcode is None ) def _is_authorizing_official_complete(self): @@ -1004,6 +1004,10 @@ class DomainRequest(TimeStampedModel): return False def _is_additional_details_complete(self): + # has_cisa_representative is True and the cisa_representative_email is not empty and is not an empty string + # OR has_cisa_representative is No + # AND + # the anything else boolean is True and there is text and it's not an empty string of text OR the boolean is No return ( ( self.has_cisa_representative is True diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 0ed038be3..825f86be6 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1655,7 +1655,8 @@ class TestDomainRequestIncomplete(TestCase): requested_domain=draft_domain, purpose="Some purpose", submitter=you, - has_cisa_representative=False, + has_cisa_representative=True, + cisa_representative_email="somerep@cisa.com", has_anything_else_text=True, anything_else="Anything else", is_policy_acknowledged=True, @@ -1695,7 +1696,8 @@ class TestDomainRequestIncomplete(TestCase): # self.domain_request.is_election_board.clear() self.domain_request.is_election_board = None self.domain_request.save() - self.assertFalse(self.domain_request._is_state_or_territory_complete()) + # is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election + self.assertTrue(self.domain_request._is_state_or_territory_complete()) def test_is_tribal_complete(self): self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.TRIBAL @@ -1706,18 +1708,18 @@ class TestDomainRequestIncomplete(TestCase): self.domain_request.tribe_name = None self.domain_request.is_election_board = None self.domain_request.save() + # is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election self.assertFalse(self.domain_request._is_tribal_complete()) def test_is_county_complete(self): self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.COUNTY - self.domain_request.about_your_organization = "Something something about your organization" self.domain_request.is_election_board = False self.domain_request.save() self.assertTrue(self.domain_request._is_county_complete()) - self.domain_request.about_your_organization = None self.domain_request.is_election_board = None self.domain_request.save() - self.assertFalse(self.domain_request._is_county_complete()) + # is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election + self.assertTrue(self.domain_request._is_county_complete()) def test_is_city_complete(self): self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.CITY @@ -1726,9 +1728,8 @@ class TestDomainRequestIncomplete(TestCase): self.assertTrue(self.domain_request._is_city_complete()) self.domain_request.is_election_board = None self.domain_request.save() - self.domain_request.refresh_from_db() - print(f"self.domain_request.is_election_board {self.domain_request.is_election_board }") - self.assertFalse(self.domain_request._is_city_complete()) + # is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election + self.assertTrue(self.domain_request._is_city_complete()) def test_is_special_district_complete(self): self.domain_request.generic_org_type = DomainRequest.OrganizationChoices.SPECIAL_DISTRICT @@ -1739,15 +1740,15 @@ class TestDomainRequestIncomplete(TestCase): self.domain_request.about_your_organization = None self.domain_request.is_election_board = None self.domain_request.save() + # is_election_board will overwrite to False bc of _update_org_type_from_generic_org_and_election self.assertFalse(self.domain_request._is_special_district_complete()) - # TODO: Fix def test_is_organization_name_and_address_complete(self): self.assertTrue(self.domain_request._is_organization_name_and_address_complete()) self.domain_request.organization_name = None self.domain_request.address_line1 = None self.domain_request.save() - self.assertFalse(self.domain_request._is_organization_name_and_address_complete()) + self.assertTrue(self.domain_request._is_organization_name_and_address_complete()) def test_is_authorizing_official_complete(self): self.assertTrue(self.domain_request._is_authorizing_official_complete()) @@ -1785,94 +1786,164 @@ class TestDomainRequestIncomplete(TestCase): self.assertFalse(self.domain_request._is_other_contacts_complete()) def test_is_additional_details_complete(self): - - # CISA Rep - No, Anything Else Radio - Anything Else Text - Filled - self.assertTrue(self.domain_request._is_additional_details_complete()) - - # CISA Rep - No, Anything Else Radio - No - self.domain_request.has_anything_else_text = False - self.assertTrue(self.domain_request._is_additional_details_complete()) - - # CISA Rep - Yes, CISA Rep Email - Yes (And has above Anything Else Radio - No) - self.domain_request.has_cisa_representative = True - self.domain_request.cisa_representative_email = "some@cisarepemail.com" - self.domain_request.save() - self.assertTrue(self.domain_request._is_additional_details_complete()) - - # # Check immediately after saving - print("After setting to None and saving:") - print(f"has_cisa_representative (before refresh): {self.domain_request.has_cisa_representative}") - print(f"cisa_representative_email (before refresh): {self.domain_request.cisa_representative_email}") - print(f"has_anything_else_text (before refresh): {self.domain_request.has_anything_else_text}") - print(f"anything_else (before refresh): {self.domain_request.anything_else}") - - # CISA Rep - Yes, CISA Rep Email - Yes, Anything Else Radio - Yes, Anything Else Text - No - self.domain_request.has_anything_else_text = True - self.domain_request.anything_else = "" - self.domain_request.save() - - # Refresh from the database - self.domain_request.refresh_from_db() - - print("After setting to None and saving:") - print(f"has_cisa_representative (after refresh): {self.domain_request.has_cisa_representative}") - print(f"cisa_representative_email (after refresh): {self.domain_request.cisa_representative_email}") - print(f"has_anything_else_text (after refresh): {self.domain_request.has_anything_else_text}") - print(f"anything_else (after refresh): {self.domain_request.anything_else}") - # has_cisa_representative (after refresh): True - # cisa_representative_email (after refresh): some@cisarepemail.com - # has_anything_else_text (after refresh): False - # anything_else (after refresh): None - - # # This ensures that if we have prefilled data, the form is prepopulated - # if self.anything_else is not None: - # self.has_anything_else_text = self.anything_else != "" - - # # This check is required to ensure that the form doesn't start out checked. - # if self.has_anything_else_text is not None: - # self.has_anything_else_text = self.anything_else != "" and self.anything_else is not None - - self.assertFalse(self.domain_request._is_additional_details_complete()) - - # CISA Rep - None, CISA Rep Email - None, Anything Else Radio - No - self.domain_request.cisa_representative_email = None - self.domain_request.has_cisa_representative = None - self.domain_request.save() - self.assertFalse(self.domain_request._is_additional_details_complete()) - - # Refresh from the database - self.domain_request.refresh_from_db() - - # # Check immediately after saving - print("After setting to None and saving:") - print(f"has_cisa_representative (before refresh): {self.domain_request.has_cisa_representative}") - print(f"cisa_representative_email (before refresh): {self.domain_request.cisa_representative_email}") - print(f"has_anything_else_text (before refresh): {self.domain_request.has_anything_else_text}") - print(f"anything_else (before refresh): {self.domain_request.anything_else}") - - # self.domain_request.has_cisa_representative = True - # self.domain_request.cisa_representative_email = "some@cisarepemail.com" - # # If you choose yes on radio button but dont have text it should error - # self.domain_request.has_anything_else_text = True - # self.domain_request.anything_else = None - # self.domain_request.save() - - # self.domain_request.anything_else = "Some text here" - # self.domain_request.save() - # self.assertFalse(self.domain_request._is_additional_details_complete()) - - # # Check immediately after saving - # print("After setting to None and saving:") - # print(f'has_cisa_representative (before refresh): {self.domain_request.has_cisa_representative}') - - # Refresh from the database - # self.domain_request.refresh_from_db() - - # # Check after refreshing from the database - # print("After refreshing from DB:") - # print(f'has_cisa_representative (after refresh): {self.domain_request.has_cisa_representative}') - - # Expect False because has_cisa_representative is None (which we now explicitly handle) + test_cases = [ + # CISA Rep - Yes + # Email - Yes + # Anything Else Radio - Yes + # Anything Else Text - Yes + { + "has_cisa_representative": True, + "cisa_representative_email": "some@cisarepemail.com", + "has_anything_else_text": True, + "anything_else": "Some text", + "expected": True, + }, + # CISA Rep - Yes + # Email - Yes + # Anything Else Radio - Yes + # Anything Else Text - None + { + "has_cisa_representative": True, + "cisa_representative_email": "some@cisarepemail.com", + "has_anything_else_text": True, + "anything_else": None, + "expected": True, + }, + # CISA Rep - Yes + # Email - Yes + # Anything Else Radio - No + # Anything Else Text - No + { + "has_cisa_representative": True, + "cisa_representative_email": "some@cisarepemail.com", + "has_anything_else_text": False, + "anything_else": None, + "expected": True, + }, + # CISA Rep - Yes + # Email - Yes + # Anything Else Radio - None + # Anything Else Text - None + { + "has_cisa_representative": True, + "cisa_representative_email": "some@cisarepemail.com", + "has_anything_else_text": None, + "anything_else": None, + "expected": False, + }, + # CISA Rep - Yes + # Email - None + # Anything Else Radio - None + # Anything Else Text - None + { + "has_cisa_representative": True, + "cisa_representative_email": None, + "has_anything_else_text": None, + "anything_else": None, + "expected": False, + }, + # CISA Rep - Yes + # Email - None + # Anything Else Radio - No + # Anything Else Text - No + # sync_yes_no will override has_cisa_representative to be False if cisa_representative_email is None + # therefore, our expected will be True + { + "has_cisa_representative": True, + # Above will be overridden to False if cisa_rep_email is None bc of sync_yes_no_form_fields + "cisa_representative_email": None, + "has_anything_else_text": False, + "anything_else": None, + "expected": True, + }, + # CISA Rep - Yes + # Email - None + # Anything Else Radio - Yes + # Anything Else Text - None + { + "has_cisa_representative": True, + # Above will be overridden to False if cisa_rep_email is None bc of sync_yes_no_form_fields + "cisa_representative_email": None, + "has_anything_else_text": True, + "anything_else": None, + "expected": True, + }, + # CISA Rep - Yes + # Email - None + # Anything Else Radio - Yes + # Anything Else Text - Yes + { + "has_cisa_representative": True, + # Above will be overridden to False if cisa_rep_email is None bc of sync_yes_no_form_fields + "cisa_representative_email": None, + "has_anything_else_text": True, + "anything_else": "Some text", + "expected": True, + }, + # CISA Rep - No + # Anything Else Radio - Yes + # Anything Else Text - Yes + { + "has_cisa_representative": False, + "cisa_representative_email": None, + "has_anything_else_text": True, + "anything_else": "Some text", + "expected": True, + }, + # CISA Rep - No + # Anything Else Radio - Yes + # Anything Else Text - None + { + "has_cisa_representative": False, + "cisa_representative_email": None, + "has_anything_else_text": True, + "anything_else": None, + "expected": True, + }, + # CISA Rep - No + # Anything Else Radio - None + # Anything Else Text - None + { + "has_cisa_representative": False, + "cisa_representative_email": None, + "has_anything_else_text": None, + "anything_else": None, + # Above is both None, so it does NOT get overwritten + "expected": False, + }, + # CISA Rep - No + # Anything Else Radio - No + # Anything Else Text - No + { + "has_cisa_representative": False, + "cisa_representative_email": None, + "has_anything_else_text": False, + "anything_else": None, + "expected": True, + }, + # CISA Rep - None + # Anything Else Radio - None + { + "has_cisa_representative": None, + "cisa_representative_email": None, + "has_anything_else_text": None, + "anything_else": None, + "expected": False, + }, + ] + for case in test_cases: + with self.subTest(case=case): + self.domain_request.has_cisa_representative = case["has_cisa_representative"] + self.domain_request.cisa_representative_email = case["cisa_representative_email"] + self.domain_request.has_anything_else_text = case["has_anything_else_text"] + self.domain_request.anything_else = case["anything_else"] + self.domain_request.save() + self.domain_request.refresh_from_db() + self.assertEqual( + self.domain_request._is_additional_details_complete(), + case["expected"], + msg=f"Failed for case: {case}", + ) def test_is_policy_acknowledgement_complete(self): self.assertTrue(self.domain_request._is_policy_acknowledgement_complete()) @@ -1881,8 +1952,8 @@ class TestDomainRequestIncomplete(TestCase): self.domain_request.is_policy_acknowledged = None self.assertFalse(self.domain_request._is_policy_acknowledgement_complete()) - def test_is_general_form_complete(self): - self.assertTrue(self.domain_request._is_general_form_complete()) - self.domain_request.organization_name = None + def test_form_complete(self): + self.assertTrue(self.domain_request._form_complete()) + self.domain_request.generic_org_type = None self.domain_request.save() - self.assertFalse(self.domain_request._is_general_form_complete()) + self.assertFalse(self.domain_request._form_complete()) From a32a228e6ccb8edf9f7a809cd65a26100c284b02 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 28 May 2024 13:06:06 -0600 Subject: [PATCH 108/214] Code cleanup --- src/registrar/assets/js/get-gov.js | 2 +- src/registrar/assets/sass/_theme/_base.scss | 13 +++++++ src/registrar/assets/sass/_theme/_forms.scss | 9 ++--- .../assets/sass/_theme/_headers.scss | 14 -------- src/registrar/assets/sass/_theme/styles.scss | 1 - .../templates/domain_request_intro.html | 1 + .../includes/finish_profile_form.html | 24 +++++++------ .../templates/includes/gov_extended_logo.html | 2 +- src/registrar/templates/profile.html | 3 +- src/registrar/templatetags/field_helpers.py | 8 +++-- src/registrar/views/domain_request.py | 12 +------ src/registrar/views/user_profile.py | 36 +++++++++++-------- src/registrar/views/utility/mixins.py | 1 - .../views/utility/permission_views.py | 4 ++- 14 files changed, 63 insertions(+), 67 deletions(-) delete mode 100644 src/registrar/assets/sass/_theme/_headers.scss diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 5db6cea54..a48d9b46b 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -873,7 +873,7 @@ function hideDeletedForms() { let fieldId = getInputFieldId(fieldName) let inputField = document.querySelector(fieldId); - let nameFieldset = document.querySelector("#profile-name-fieldset"); + let nameFieldset = document.querySelector("#profile-name-group"); if (nameFieldset){ nameFieldset.classList.remove("display-none"); } diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index 5e7fa0c6c..e88d75f4e 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -198,3 +198,16 @@ abbr[title] { height: 1.25em !important; } } + +// Define some styles for the .gov header/logo +.usa-logo button { + color: #{$dhs-dark-gray-85}; + font-weight: 700; + font-family: family('sans'); + font-size: 1.6rem; + line-height: 1.1; +} + +.usa-logo button.usa-button--unstyled.disabled-button:hover{ + color: #{$dhs-dark-gray-85}; +} diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index 46d41059a..b5229fae1 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -15,9 +15,6 @@ .usa-form--extra-large { max-width: none; - .usa-summary-box { - max-width: 600px; - } } .usa-form--text-width { @@ -30,7 +27,7 @@ } } -.usa-form-readonly { +.usa-form-editable { border-top: 2px #{$dhs-dark-gray-15} solid; .bold-usa-label label.usa-label{ @@ -41,14 +38,14 @@ font-weight: bold; } - &.usa-form-readonly--no-border { + &.usa-form-editable--no-border { border-top: None; margin-top: 0px !important; } } -.usa-form-readonly > .usa-form-group:first-of-type { +.usa-form-editable > .usa-form-group:first-of-type { margin-top: unset; } diff --git a/src/registrar/assets/sass/_theme/_headers.scss b/src/registrar/assets/sass/_theme/_headers.scss deleted file mode 100644 index 2e3992e5a..000000000 --- a/src/registrar/assets/sass/_theme/_headers.scss +++ /dev/null @@ -1,14 +0,0 @@ -@use "uswds-core" as *; -@use "cisa_colors" as *; - -.usa-logo button { - color: #{$dhs-dark-gray-85}; - font-weight: 700; - font-family: family('sans'); - font-size: 1.6rem; - line-height: 1.1; -} - -.usa-logo button.usa-button--unstyled.disabled-button:hover{ - color: #{$dhs-dark-gray-85}; -} diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss index e24618a23..64b113a29 100644 --- a/src/registrar/assets/sass/_theme/styles.scss +++ b/src/registrar/assets/sass/_theme/styles.scss @@ -20,7 +20,6 @@ @forward "tables"; @forward "sidenav"; @forward "register-form"; -@forward "_headers"; /*-------------------------------------------------- --- Admin ---------------------------------*/ diff --git a/src/registrar/templates/domain_request_intro.html b/src/registrar/templates/domain_request_intro.html index 259657ad4..c0b566799 100644 --- a/src/registrar/templates/domain_request_intro.html +++ b/src/registrar/templates/domain_request_intro.html @@ -15,6 +15,7 @@

If you have all the information you need, completing your domain request might take around 15 minutes.

+ {% block form_buttons %}
{% endif %} -
\ No newline at end of file +
diff --git a/src/registrar/templates/profile.html b/src/registrar/templates/profile.html index eeab1f945..12441da66 100644 --- a/src/registrar/templates/profile.html +++ b/src/registrar/templates/profile.html @@ -36,5 +36,4 @@ Edit your User Profile | {% include "includes/profile_form.html" with form=form %}
-{% endblock %} - +{% endblock content_bottom %} diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index a7aa9d663..be78099db 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -95,10 +95,12 @@ def input_with_errors(context, field=None): # noqa: C901 elif key == "show_edit_button": # Hide the primary input field. # Used such that we can toggle it with JS - if "display-none" not in classes and isinstance(value, bool) and value: + if "display-none" not in classes: classes.append("display-none") - # Set this as a context value so we know what we're going to display - context["show_edit_button"] = value + + # Tag that this form contains the edit button. + if "usa-form-editable" not in group_classes: + group_classes.append("usa-form-editable") attrs["id"] = field.auto_id diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 1d14e4b57..a90eaf271 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -15,7 +15,6 @@ from registrar.models.user import User from registrar.utility import StrEnum from registrar.views.utility import StepsHelper from registrar.views.utility.permission_views import DomainRequestPermissionDeleteView -from waffle.decorators import flag_is_active, waffle_flag from .utility import ( DomainRequestPermissionView, @@ -23,6 +22,7 @@ from .utility import ( DomainRequestWizardPermissionView, ) +from waffle.decorators import flag_is_active logger = logging.getLogger(__name__) @@ -400,13 +400,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): def get_step_list(self) -> list: """Dynamically generated list of steps in the form wizard.""" step_list = [] - excluded_steps = [Step.YOUR_CONTACT] - should_exclude = flag_is_active(self.request, "profile_feature") for step in Step: - - if should_exclude and step in excluded_steps: - continue - condition = self.WIZARD_CONDITIONS.get(step, True) if callable(condition): condition = condition(self) @@ -546,10 +540,6 @@ class YourContact(DomainRequestWizard): template_name = "domain_request_your_contact.html" forms = [forms.YourContactForm] - @waffle_flag("!profile_feature") # type: ignore - def dispatch(self, request, *args, **kwargs): # type: ignore - return super().dispatch(request, *args, **kwargs) - class OtherContacts(DomainRequestWizard): template_name = "domain_request_other_contacts.html" diff --git a/src/registrar/views/user_profile.py b/src/registrar/views/user_profile.py index b12812849..a4756f482 100644 --- a/src/registrar/views/user_profile.py +++ b/src/registrar/views/user_profile.py @@ -96,10 +96,6 @@ class FinishProfileSetupView(UserProfileView): """This view forces the user into providing additional details that we may have missed from Login.gov""" - template_name = "finish_profile_setup.html" - form_class = FinishSetupProfileForm - model = Contact - class RedirectType(Enum): """ Enums for each type of redirection. Enforces behaviour on `get_redirect_url()`. @@ -116,8 +112,17 @@ class FinishProfileSetupView(UserProfileView): BACK_TO_SELF = "back_to_self" COMPLETE_SETUP = "complete_setup" - redirect_type = None - all_redirect_types = [r.value for r in RedirectType] + @classmethod + def get_all_redirect_types(cls) -> list[str]: + """Returns the value of every redirect type defined in this enum.""" + return [r.value for r in cls] + + template_name = "finish_profile_setup.html" + form_class = FinishSetupProfileForm + model = Contact + + all_redirect_types = RedirectType.get_all_redirect_types() + redirect_type: RedirectType def get_context_data(self, **kwargs): @@ -151,16 +156,18 @@ class FinishProfileSetupView(UserProfileView): """ # Update redirect type based on the query parameter if present - redirect_type = request.GET.get("redirect", self.RedirectType.BACK_TO_SELF.value) - if redirect_type in self.all_redirect_types: - self.redirect_type = self.RedirectType(redirect_type) + default_redirect_value = self.RedirectType.BACK_TO_SELF.value + redirect_value = request.GET.get("redirect", default_redirect_value) + + if redirect_value in self.all_redirect_types: + # If the redirect value is a preexisting value in our enum, set it to that. + self.redirect_type = self.RedirectType(redirect_value) else: - # If the redirect type is undefined, then we assume that - # we are specifying a particular page to redirect to. + # If the redirect type is undefined, then we assume that we are specifying a particular page to redirect to. self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE # Store the page that we want to redirect to for later use - request.session["redirect_viewname"] = str(redirect_type) + request.session["redirect_viewname"] = str(redirect_value) return super().dispatch(request, *args, **kwargs) @@ -183,8 +190,7 @@ class FinishProfileSetupView(UserProfileView): def get_success_url(self): """Redirect to the nameservers page for the domain.""" - redirect_url = self.get_redirect_url() - return redirect_url + return self.get_redirect_url() def get_redirect_url(self): """ @@ -220,7 +226,7 @@ class FinishProfileSetupView(UserProfileView): query_params = {} # Quote cleans up the value so that it can be used in a url - if self.redirect_type: + if self.redirect_type and self.redirect_type.value: query_params["redirect"] = quote(self.redirect_type.value) # Generate the full url from the given query params diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 0b8a7605a..926ee4a8c 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -296,7 +296,6 @@ class UserDeleteDomainRolePermission(PermissionsLoginMixin): domain_pk = self.kwargs["pk"] user_pk = self.kwargs["user_pk"] - # Check if the user is authenticated if not self.request.user.is_authenticated: return False diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index eb40621b5..d35647af2 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -3,7 +3,9 @@ import abc # abstract base class from django.views.generic import DetailView, DeleteView, TemplateView -from registrar.models import Domain, DomainRequest, DomainInvitation, UserDomainRole, Contact +from registrar.models import Domain, DomainRequest, DomainInvitation +from registrar.models.contact import Contact +from registrar.models.user_domain_role import UserDomainRole from .mixins import ( DomainPermission, From e34c72426476ea59ff6e1ef6ea2d28928ae89730 Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 28 May 2024 13:18:41 -0600 Subject: [PATCH 109/214] Minor refactor --- src/registrar/assets/js/get-gov.js | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index a48d9b46b..baa3f4bbd 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -842,21 +842,14 @@ function hideDeletedForms() { */ (function finishUserSetupListener() { - function getInputFieldId(fieldName){ - return `#id_${fieldName}` - } - - function getReadonlyFieldId(fieldName){ - return `#${fieldName}__edit-button-readonly` + function getInputField(fieldName){ + return document.querySelector(`#id_${fieldName}`) } // Shows the hidden input field and hides the readonly one function showInputFieldHideReadonlyField(fieldName, button) { - let inputId = getInputFieldId(fieldName) - let inputField = document.querySelector(inputId) - - let readonlyId = getReadonlyFieldId(fieldName) - let readonlyField = document.querySelector(readonlyId) + let inputField = getInputField(fieldName) + let readonlyField = document.querySelector(`#${fieldName}__edit-button-readonly`) readonlyField.classList.toggle('display-none'); inputField.classList.toggle('display-none'); @@ -868,18 +861,16 @@ function hideDeletedForms() { } } - function handleFullNameField(fieldName) { + function handleFullNameField(fieldName = "full_name") { // Remove the display-none class from the nearest parent div - let fieldId = getInputFieldId(fieldName) - let inputField = document.querySelector(fieldId); - let nameFieldset = document.querySelector("#profile-name-group"); if (nameFieldset){ nameFieldset.classList.remove("display-none"); } + // Hide the "full_name" field + let inputField = getInputField(fieldName); if (inputField) { - // Hide the "full_name" field inputFieldParentDiv = inputField.closest("div"); if (inputFieldParentDiv) { inputFieldParentDiv.classList.add("display-none"); @@ -893,11 +884,12 @@ function hideDeletedForms() { button.disabled = true if (fieldName == "full_name"){ - handleFullNameField(fieldName); + handleFullNameField(); }else { showInputFieldHideReadonlyField(fieldName, button); } + // Hide the button itself button.classList.add("display-none"); // Unlock after it completes From 9f2a9594f6403346c667b1e6b5e0308ad239eab6 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 28 May 2024 12:39:57 -0700 Subject: [PATCH 110/214] Add in other contacts rationale check and clean up tests --- src/registrar/models/domain_request.py | 8 ++++---- src/registrar/tests/test_models.py | 17 +++++++++++++++- src/registrar/tests/test_views_request.py | 24 ++--------------------- src/registrar/views/domain_request.py | 3 --- 4 files changed, 22 insertions(+), 30 deletions(-) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index ed98433fa..869a6bcca 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -989,7 +989,7 @@ class DomainRequest(TimeStampedModel): return self.submitter is not None def _is_other_contacts_complete(self): - # If the object even exists and double check for + # If the object even exists and double check each part is filled out if ( self.has_other_contacts() and self.other_contacts.filter( @@ -999,6 +999,8 @@ class DomainRequest(TimeStampedModel): email__isnull=False, phone__isnull=False, ).exists() + # Radio button is No + has rationale + or (self.has_other_contacts() is False and self.no_other_contacts_rationale is not None) ): return True return False @@ -1030,7 +1032,7 @@ class DomainRequest(TimeStampedModel): and self._is_requested_domain_complete() and self._is_purpose_complete() and self._is_submitter_complete() - and self._is_other_contacts_complete() + and self._is_other_contacts_complete() # -- ISSUE HERE and self._is_additional_details_complete() and self._is_policy_acknowledgement_complete() ) @@ -1055,8 +1057,6 @@ class DomainRequest(TimeStampedModel): is_complete = False if not is_complete or not self._is_general_form_complete(): - print("!!!! We are in the False if statement - form is not complete") return False - print("!!!! We are in the True if statement - form is complete") return True diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 825f86be6..ee784af87 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1655,6 +1655,7 @@ class TestDomainRequestIncomplete(TestCase): requested_domain=draft_domain, purpose="Some purpose", submitter=you, + no_other_contacts_rationale=None, has_cisa_representative=True, cisa_representative_email="somerep@cisa.com", has_anything_else_text=True, @@ -1781,10 +1782,24 @@ class TestDomainRequestIncomplete(TestCase): contact.save() self.assertFalse(self.domain_request._is_other_contacts_complete()) - def test_is_other_contacts_complete(self): + def test_is_other_contacts_complete_all_none(self): self.domain_request.other_contacts.clear() self.assertFalse(self.domain_request._is_other_contacts_complete()) + def test_is_other_contacts_False_and_has_rationale(self): + # Click radio button "No" for no other contacts and give rationale + self.domain_request.other_contacts.clear() + self.domain_request.other_contacts.exists = False + self.domain_request.no_other_contacts_rationale = "Some rationale" + self.assertTrue(self.domain_request._is_other_contacts_complete()) + + def test_is_other_contacts_False_and_NO_rationale(self): + # Click radio button "No" for no other contacts and DONT give rationale + self.domain_request.other_contacts.clear() + self.domain_request.other_contacts.exists = False + self.domain_request.no_other_contacts_rationale = None + self.assertFalse(self.domain_request._is_other_contacts_complete()) + def test_is_additional_details_complete(self): test_cases = [ # CISA Rep - Yes diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index d1ad07ebb..8816840da 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -473,9 +473,7 @@ class DomainRequestTests(TestWithUser, WebTest): @boto3_mocking.patching def test_domain_request_form_submission_incomplete(self): num_pages_tested = 0 - # elections, type_of_work, tribal_government - SKIPPED_PAGES = 3 - # num_pages = len(self.TITLES) - SKIPPED_PAGES + # skipping elections, type_of_work, tribal_government intro_page = self.app.get(reverse("domain-request:")) # django-webtest does not handle cookie-based sessions well because it keeps @@ -772,7 +770,7 @@ class DomainRequestTests(TestWithUser, WebTest): # Follow the redirect to the next form page self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) review_page = requirements_result.follow() - review_form = review_page.forms[0] + # review_form = review_page.forms[0] # Review page contains all the previously entered data # Let's make sure the long org name is displayed @@ -789,24 +787,6 @@ class DomainRequestTests(TestWithUser, WebTest): self.assertNotContains(review_page, "You are about to submit a domain request for city.gov") self.assertContains(review_page, "You can’t submit this request because it’s incomplete.") - # DO WE NEED TO BLOCK SUBMISSIONS WITH INCOMPLETE FORMS ON THE BACKEND @Alysia? - - # final submission results in a redirect to the "finished" URL - # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - # with less_console_noise(): - # review_result = review_form.submit() - - # self.assertEqual(review_result.status_code, 302) - # self.assertEqual(review_result["Location"], "/request/finished/") - # num_pages_tested += 1 - - # # following this redirect is a GET request, so include the cookie - # # here too. - # self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - # with less_console_noise(): - # final_result = review_result.follow() - # self.assertContains(final_result, "Thanks for your domain request!") - # This is the start of a test to check an existing domain_request, it currently # does not work and results in errors as noted in: # https://github.com/cisagov/getgov/pull/728 diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 7e8f9e1d4..d13c88599 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -381,12 +381,10 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): context_stuff = {} if DomainRequest._form_complete(self.domain_request): - print("!!!!!!!in form complete section") modal_button = '" context_stuff = { "form_titles": self.TITLES, "steps": self.steps, - # Add information about which steps should be unlocked "visited": self.storage.get("step_history", []), "is_federal": self.domain_request.is_federal(), "modal_button": modal_button, @@ -397,7 +395,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): "review_form_is_complete": True, } else: # form is not complete - print("!!!!!!! form is not complete") modal_button = '" context_stuff = { "form_titles": self.TITLES, From 54c5052b2bf001fbc5cadd2f8c99cd5cedfa888d Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 28 May 2024 12:46:41 -0700 Subject: [PATCH 111/214] Fix merge conflict --- src/registrar/views/domain_request.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index d13c88599..6659b3fab 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -22,6 +22,8 @@ from .utility import ( DomainRequestWizardPermissionView, ) +from waffle.decorators import flag_is_active + logger = logging.getLogger(__name__) @@ -376,8 +378,6 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): def get_context_data(self): """Define context for access on all wizard pages.""" - # Build the submit button that we'll pass to the modal. - # Concatenate the modal header that we'll pass to the modal. context_stuff = {} if DomainRequest._form_complete(self.domain_request): @@ -393,6 +393,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): "modal_description": "Once you submit this request, you won’t be able to edit it until we review it.\ You’ll only be able to withdraw your request.", "review_form_is_complete": True, + "has_profile_feature_flag": flag_is_active(self.request, "profile_feature"), } else: # form is not complete modal_button = '" @@ -406,6 +407,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): "modal_description": "You can’t submit this request because it’s incomplete.\ Click return to request and complete the sections that are missing information.", "review_form_is_complete": False, + "has_profile_feature_flag": flag_is_active(self.request, "profile_feature"), } return context_stuff From 6668a292a3558a25ddc8cc173a788cc2bf2f8633 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 28 May 2024 12:50:38 -0700 Subject: [PATCH 112/214] Remove duplicate waffle header --- src/registrar/views/domain_request.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index 5e1dc1844..a098f778a 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -24,8 +24,6 @@ from .utility import ( from waffle.decorators import flag_is_active -from waffle.decorators import flag_is_active - logger = logging.getLogger(__name__) From b6e931e5a9cd780ed868b49377f3ccc76a70a5db Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 28 May 2024 13:02:34 -0700 Subject: [PATCH 113/214] Remove comment --- src/registrar/models/domain_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 869a6bcca..57a1c53d7 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -1032,7 +1032,7 @@ class DomainRequest(TimeStampedModel): and self._is_requested_domain_complete() and self._is_purpose_complete() and self._is_submitter_complete() - and self._is_other_contacts_complete() # -- ISSUE HERE + and self._is_other_contacts_complete() and self._is_additional_details_complete() and self._is_policy_acknowledgement_complete() ) From 1c6a565ec3508b4a262dddef9478b9d2f13656bd Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 28 May 2024 13:08:33 -0700 Subject: [PATCH 114/214] Fix test in views with new wording --- src/registrar/tests/test_views_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/tests/test_views_request.py b/src/registrar/tests/test_views_request.py index 8816840da..f40c72114 100644 --- a/src/registrar/tests/test_views_request.py +++ b/src/registrar/tests/test_views_request.py @@ -2705,7 +2705,7 @@ class DomainRequestTests(TestWithUser, WebTest): review_page = self.app.get(reverse("domain-request:review")) self.assertContains(review_page, "toggle-submit-domain-request") - self.assertContains(review_page, "You are about to submit an incomplete request") + self.assertContains(review_page, "You can’t submit this request because it’s incomplete.") class DomainRequestTestDifferentStatuses(TestWithUser, WebTest): From 05868966c56681db29484416a1f546602b9e62b9 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 28 May 2024 13:14:29 -0700 Subject: [PATCH 115/214] pally fix maybe --- src/registrar/templates/includes/modal.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/templates/includes/modal.html b/src/registrar/templates/includes/modal.html index cb99fa321..c745fc946 100644 --- a/src/registrar/templates/includes/modal.html +++ b/src/registrar/templates/includes/modal.html @@ -41,7 +41,7 @@ {% elif not is_domain_request_form or review_form_is_complete %}
{% public_site_url "help/account-management/#get-help-with-login.gov" as login_help_url %} - {% with show_readonly=True add_class="display-none" group_classes="usa-form-editable padding-top-2 bold-usa-label" %} + {% with show_readonly=True add_class="display-none" group_classes="usa-form-editable usa-form-editable padding-top-2 bold-usa-label" %} {% with link_href=login_help_url %} {% with sublabel_text="We recommend using your work email for your .gov account. If the wrong email is displayed below, you’ll need to update your Login.gov account and log back in. Get help with your Login.gov account." %} {% with link_text="Get help with your Login.gov account" target_blank=True do_not_show_max_chars=True %} @@ -62,11 +62,11 @@ {% endwith %} {% endwith %} - {% with show_edit_button=True show_readonly=True group_classes="padding-top-2" %} + {% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %} {% input_with_errors form.title %} {% endwith %} - {% with show_edit_button=True show_readonly=True group_classes="padding-top-2" %} + {% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %} {% with add_class="usa-input--medium" %} {% input_with_errors form.phone %} {% endwith %} diff --git a/src/registrar/templatetags/field_helpers.py b/src/registrar/templatetags/field_helpers.py index be78099db..b72f77e21 100644 --- a/src/registrar/templatetags/field_helpers.py +++ b/src/registrar/templatetags/field_helpers.py @@ -97,10 +97,6 @@ def input_with_errors(context, field=None): # noqa: C901 # Used such that we can toggle it with JS if "display-none" not in classes: classes.append("display-none") - - # Tag that this form contains the edit button. - if "usa-form-editable" not in group_classes: - group_classes.append("usa-form-editable") attrs["id"] = field.auto_id diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index bfe4fd142..4c0ba08ee 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -628,9 +628,12 @@ class FinishUserProfileTests(TestWithUser, WebTest): completed_setup_page = self._submit_form_webtest(finish_setup_page.form, follow=True) self.assertEqual(completed_setup_page.status_code, 200) + # Assert that we're on the domain request page - self.assertContains(completed_setup_page, "How we’ll reach you") - self.assertContains(completed_setup_page, "Your contact information") + self.assertNotContains(completed_setup_page, "Finish setting up your profile") + self.assertNotContains(completed_setup_page, "What contact information should we use to reach you?") + + self.assertContains(completed_setup_page, "You’re about to start your .gov domain request") @less_console_noise_decorator def test_new_user_with_profile_feature_off(self): @@ -645,8 +648,11 @@ class FinishUserProfileTests(TestWithUser, WebTest): when profile_feature is off but not the setup page""" with override_flag("profile_feature", active=False): response = self.client.get("/request/") - self.assertContains(response, "How we’ll reach you") - self.assertContains(response, "Your contact information") + + self.assertNotContains(response, "Finish setting up your profile") + self.assertNotContains(response, "What contact information should we use to reach you?") + + self.assertContains(response, "You’re about to start your .gov domain request") class UserProfileTests(TestWithUser, WebTest): @@ -806,11 +812,6 @@ class UserProfileTests(TestWithUser, WebTest): profile_page = self.app.get(reverse("user-profile")) session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - profile_form = profile_page.form - profile_page = profile_form.submit() - - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - profile_form = profile_page.form profile_form["title"] = "sample title" profile_form["phone"] = "(201) 555-1212" From 0a079c0a8633864256baaecc843dd3d2b26e804f Mon Sep 17 00:00:00 2001 From: zandercymatics <141044360+zandercymatics@users.noreply.github.com> Date: Tue, 28 May 2024 14:18:06 -0600 Subject: [PATCH 117/214] Lint --- .../management/commands/copy_names_from_contacts_to_users.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/management/commands/copy_names_from_contacts_to_users.py b/src/registrar/management/commands/copy_names_from_contacts_to_users.py index 602a3f0b7..384029400 100644 --- a/src/registrar/management/commands/copy_names_from_contacts_to_users.py +++ b/src/registrar/management/commands/copy_names_from_contacts_to_users.py @@ -111,7 +111,6 @@ class Command(BaseCommand): {TerminalColors.ENDC}""", # noqa ) - # Get the fields that exist on both User and Contact. Excludes id. common_fields = DomainHelper.get_common_fields(User, Contact) if "email" in common_fields: From 0b0f8a4e1af5355041a8baef4242be09d862ec41 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 28 May 2024 15:55:59 -0700 Subject: [PATCH 118/214] Maybe Pa11y fix: --- src/registrar/templates/includes/modal.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/registrar/templates/includes/modal.html b/src/registrar/templates/includes/modal.html index c745fc946..04363c2c2 100644 --- a/src/registrar/templates/includes/modal.html +++ b/src/registrar/templates/includes/modal.html @@ -39,13 +39,15 @@ Cancel - {% elif not is_domain_request_form or review_form_is_complete %} + {% elif is_domain_request_form or review_form_is_complete %} + + {% else %} {% endif %} From a7e0ddc44358410af6571e971ba17db3498ac7f8 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 28 May 2024 16:08:09 -0700 Subject: [PATCH 119/214] This should fix pa11y --- src/registrar/views/domain_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/registrar/views/domain_request.py b/src/registrar/views/domain_request.py index a098f778a..7fa8d3384 100644 --- a/src/registrar/views/domain_request.py +++ b/src/registrar/views/domain_request.py @@ -396,7 +396,7 @@ class DomainRequestWizard(DomainRequestWizardPermissionView, TemplateView): "has_profile_feature_flag": flag_is_active(self.request, "profile_feature"), } else: # form is not complete - modal_button = '" + modal_button = '" context_stuff = { "form_titles": self.TITLES, "steps": self.steps, From a1bf9085a7bb81694158b2f090244b397f5dde10 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 28 May 2024 16:17:30 -0700 Subject: [PATCH 120/214] Change modal logic back --- src/registrar/templates/includes/modal.html | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/registrar/templates/includes/modal.html b/src/registrar/templates/includes/modal.html index 04363c2c2..cb99fa321 100644 --- a/src/registrar/templates/includes/modal.html +++ b/src/registrar/templates/includes/modal.html @@ -39,15 +39,13 @@ Cancel - {% elif is_domain_request_form or review_form_is_complete %} - - {% else %} + {% elif not is_domain_request_form or review_form_is_complete %} {% endif %} From 3f276f1bdf6e643390e85c00f7f6fe95367b83b3 Mon Sep 17 00:00:00 2001 From: Rebecca Hsieh Date: Tue, 28 May 2024 16:32:04 -0700 Subject: [PATCH 121/214] Remove extraneous comment --- src/registrar/tests/test_models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index ee784af87..7b4b9fccd 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -1637,7 +1637,6 @@ class TestDomainRequestIncomplete(TestCase): email="testy2@town.com", phone="(555) 555 5557", ) - # domain, _ = Domain.objects.get_or_create(name="MeowardMeowardMeoward.gov") alt, _ = Website.objects.get_or_create(website="MeowardMeowardMeoward1.gov") current, _ = Website.objects.get_or_create(website="MeowardMeowardMeoward.com") self.domain_request = DomainRequest.objects.create( From c04b9e235045d40bf1de0fc22c54ae9466fb2bce Mon Sep 17 00:00:00 2001 From: Rachid Mrad Date: Tue, 28 May 2024 19:35:21 -0400 Subject: [PATCH 122/214] add total items, layout tweaks --- src/registrar/assets/js/get-gov.js | 72 +++++++++++++++++---- src/registrar/templates/home.html | 23 ++++--- src/registrar/views/domain_requests_json.py | 1 + src/registrar/views/domains_json.py | 1 + 4 files changed, 76 insertions(+), 21 deletions(-) diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index b46596a49..3d40c132a 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -879,6 +879,33 @@ function unloadModals() { window.modal.off(); } +/** + * Helper function that scrolls to an element + * @param {string} attributeName - The string "class" or "id" + * @param {string} attributeValue - The class or id name + */ +function ScrollToElement(attributeName, attributeValue) { + let targetEl = null; + + if (attributeName === 'class') { + targetEl = document.getElementsByClassName(attributeValue)[0]; + } else if (attributeName === 'id') { + targetEl = document.getElementById(attributeValue); + } else { + console.log('Error: unknown attribute name provided.'); + return; // Exit the function if an invalid attributeName is provided + } + + if (targetEl) { + const rect = targetEl.getBoundingClientRect(); + const scrollTop = window.scrollY || document.documentElement.scrollTop; + window.scrollTo({ + top: rect.top + scrollTop, + behavior: 'smooth' // Optional: for smooth scrolling + }); + } +} + /** * An IIFE that listens for DOM Content to be loaded, then executes. This function * initializes the domains list and associated functionality on the home page of the app. @@ -890,6 +917,7 @@ document.addEventListener('DOMContentLoaded', function() { let currentOrder = 'asc'; let domainsWrapper = document.querySelector('.domains-wrapper'); let noDomainsWrapper = document.querySelector('.no-domains-wrapper'); + let hasLoaded = false; /** * Loads rows in the domains list, as well as updates pagination around the domains list @@ -897,8 +925,9 @@ document.addEventListener('DOMContentLoaded', function() { * @param {*} page - the page number of the results (starts with 1) * @param {*} sortBy - the sort column option * @param {*} order - the sort order {asc, desc} + * @param {*} loaded - control for the scrollToElement functionality */ - function loadPage(page, sortBy = currentSortBy, order = currentOrder) { + function loadPage(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded) { //fetch json of page of domains, given page # and sort fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}`) .then(response => response.json()) @@ -959,9 +988,13 @@ document.addEventListener('DOMContentLoaded', function() { }); // initialize tool tips immediately after the associated DOM elements are added initializeTooltips(); + if (loaded) + ScrollToElement('id', 'domains-header'); + + hasLoaded = true; // update pagination - updatePagination(data.page, data.num_pages, data.has_previous, data.has_next); + updateDomainsPagination(data.page, data.num_pages, data.has_previous, data.has_next, data.total); currentPage = page; currentSortBy = sortBy; currentOrder = order; @@ -976,19 +1009,23 @@ document.addEventListener('DOMContentLoaded', function() { * @param {*} hasPrevious - if there is a page of results prior to the current page * @param {*} hasNext - if there is a page of results after the current page */ - function updatePagination(currentPage, numPages, hasPrevious, hasNext) { + function updateDomainsPagination(currentPage, numPages, hasPrevious, hasNext, totalItems) { // identify the DOM element where the pagination will be inserted + const counterContainer = document.querySelector('#domains-pagination .usa-pagination__counter'); const paginationContainer = document.querySelector('#domains-pagination .usa-pagination__list'); + counterContainer.innerHTML = ''; paginationContainer.innerHTML = ''; // pagination should only be displayed if there are more than one pages of results paginationContainer.classList.toggle('display-none', numPages <= 1); + counterContainer.innerHTML = `${totalItems} Domains`; + if (hasPrevious) { const prevPageItem = document.createElement('li'); prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; prevPageItem.innerHTML = ` - + @@ -1003,7 +1040,7 @@ document.addEventListener('DOMContentLoaded', function() { const pageItem = document.createElement('li'); pageItem.className = 'usa-pagination__item usa-pagination__page-no'; pageItem.innerHTML = ` - ${i} + ${i} `; if (i === currentPage) { pageItem.querySelector('a').classList.add('usa-current'); @@ -1017,7 +1054,7 @@ document.addEventListener('DOMContentLoaded', function() { const nextPageItem = document.createElement('li'); nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; nextPageItem.innerHTML = ` - + Next Next

Domains

+

Domains