diff --git a/src/registrar/admin.py b/src/registrar/admin.py index a35aa056d..ec5c10d51 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -163,6 +163,18 @@ class MyUserAdminForm(UserChangeForm): "user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False), } + # Loads "tabtitle" for this admin page so that on render the + # element will only have the model name instead of + # the default string loaded by native Django admin code. + # (Eg. instead of "Select contact to change", display "Contacts") + # see "base_site.html" for the <title> code. + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title() + # Get the filtered values + return super().changelist_view(request, extra_context=extra_context) + def __init__(self, *args, **kwargs): """Custom init to modify the user form""" super(MyUserAdminForm, self).__init__(*args, **kwargs) @@ -662,6 +674,18 @@ class CustomLogEntryAdmin(LogEntryAdmin): "user_url", ] + # Loads "tabtitle" for this admin page so that on render the <title> + # element will only have the model name instead of + # the default string loaded by native Django admin code. + # (Eg. instead of "Select contact to change", display "Contacts") + # see "base_site.html" for the <title> code. + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title() + # Get the filtered values + return super().changelist_view(request, extra_context=extra_context) + # We name the custom prop 'resource' because linter # is not allowing a short_description attr on it # This gets around the linter limitation, for now. @@ -681,13 +705,6 @@ class CustomLogEntryAdmin(LogEntryAdmin): change_form_template = "admin/change_form_no_submit.html" add_form_template = "admin/change_form_no_submit.html" - # Select log entry to change -> Log entries - def changelist_view(self, request, extra_context=None): - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "Log entries" - return super().changelist_view(request, extra_context=extra_context) - # #786: Skipping on updating audit log tab titles for now # def change_view(self, request, object_id, form_url="", extra_context=None): # if extra_context is None: @@ -768,6 +785,18 @@ class AdminSortFields: class AuditedAdmin(admin.ModelAdmin): """Custom admin to make auditing easier.""" + # Loads "tabtitle" for this admin page so that on render the <title> + # element will only have the model name instead of + # the default string loaded by native Django admin code. + # (Eg. instead of "Select contact to change", display "Contacts") + # see "base_site.html" for the <title> code. + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title() + # Get the filtered values + return super().changelist_view(request, extra_context=extra_context) + def history_view(self, request, object_id, extra_context=None): """On clicking 'History', take admin to the auditlog view for an object.""" return HttpResponseRedirect( @@ -1168,6 +1197,18 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): extra_context = {"domain_requests": domain_requests, "domains": domains, "portfolios": portfolios} return super().change_view(request, object_id, form_url, extra_context) + # Loads "tabtitle" for this admin page so that on render the <title> + # element will only have the model name instead of + # the default string loaded by native Django admin code. + # (Eg. instead of "Select contact to change", display "Contacts") + # see "base_site.html" for the <title> code. + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title() + # Get the filtered values + return super().changelist_view(request, extra_context=extra_context) + class HostIPInline(admin.StackedInline): """Edit an ip address on the host page.""" @@ -1192,14 +1233,6 @@ class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin): search_help_text = "Search by domain or host name." inlines = [HostIPInline] - # Select host to change -> Host - def changelist_view(self, request, extra_context=None): - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "Host" - # Get the filtered values - return super().changelist_view(request, extra_context=extra_context) - class HostIpResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -1215,14 +1248,6 @@ class HostIpAdmin(AuditedAdmin, ImportExportModelAdmin): resource_classes = [HostIpResource] model = models.HostIP - # Select host ip to change -> Host ip - def changelist_view(self, request, extra_context=None): - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "Host IP" - # Get the filtered values - return super().changelist_view(request, extra_context=extra_context) - class ContactResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -1344,14 +1369,6 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin): return super().change_view(request, object_id, form_url, extra_context=extra_context) - # Select contact to change -> Contacts - def changelist_view(self, request, extra_context=None): - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "Contacts" - # Get the filtered values - return super().changelist_view(request, extra_context=extra_context) - def save_model(self, request, obj, form, change): # Clear warning messages before saving storage = messages.get_messages(request) @@ -1667,14 +1684,6 @@ class DomainInvitationAdmin(BaseInvitationAdmin): # Override for the delete confirmation page on the domain table (bulk delete action) delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html" - # Select domain invitations to change -> Domain invitations - def changelist_view(self, request, extra_context=None): - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "Domain invitations" - # Get the filtered values - return super().changelist_view(request, extra_context=extra_context) - def change_view(self, request, object_id, form_url="", extra_context=None): """Override the change_view to add the invitation obj for the change_form_object_tools template""" @@ -1819,14 +1828,6 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): get_roles.short_description = "Member access" # type: ignore - # Select portfolio invitations to change -> Portfolio invitations - def changelist_view(self, request, extra_context=None): - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "Portfolio invitations" - # Get the filtered values - return super().changelist_view(request, extra_context=extra_context) - def save_model(self, request, obj, form, change): """ Override the save_model method. @@ -2216,14 +2217,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): readonly_fields.extend([field for field in self.analyst_readonly_fields]) return readonly_fields # Read-only fields for analysts - # Select domain information to change -> Domain information - def changelist_view(self, request, extra_context=None): - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "Domain information" - # Get the filtered values - return super().changelist_view(request, extra_context=extra_context) - def formfield_for_foreignkey(self, db_field, request, **kwargs): """Customize the behavior of formfields with foreign key relationships. This will customize the behavior of selects. Customized behavior includes sorting of objects in list.""" @@ -3044,11 +3037,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): if next_char.isdigit(): should_apply_default_filter = True - # Select domain request to change -> Domain requests - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "Domain requests" - if should_apply_default_filter: # modify the GET of the request to set the selected filter modified_get = copy.deepcopy(request.GET) @@ -4105,14 +4093,6 @@ class DraftDomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): # If no redirection is needed, return the original response return response - # Select draft domain to change -> Draft domains - def changelist_view(self, request, extra_context=None): - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "Draft domains" - # Get the filtered values - return super().changelist_view(request, extra_context=extra_context) - class PublicContactResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the @@ -4534,14 +4514,6 @@ class UserGroupAdmin(AuditedAdmin): def user_group(self, obj): return obj.name - # Select user groups to change -> User groups - def changelist_view(self, request, extra_context=None): - if extra_context is None: - extra_context = {} - extra_context["tabtitle"] = "User groups" - # Get the filtered values - return super().changelist_view(request, extra_context=extra_context) - class WaffleFlagAdmin(FlagAdmin): """Custom admin implementation of django-waffle's Flag class""" @@ -4558,6 +4530,13 @@ class WaffleFlagAdmin(FlagAdmin): if extra_context is None: extra_context = {} extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag") + + # Loads "tabtitle" for this admin page so that on render the <title> + # element will only have the model name instead of + # the default string loaded by native Django admin code. + # (Eg. instead of "Select waffle flags to change", display "Waffle Flags") + # see "base_site.html" for the <title> code. + extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title() return super().changelist_view(request, extra_context=extra_context) diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index ae246b05c..d8664d5bf 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -5284,7 +5284,10 @@ const setUpModal = baseComponent => { overlayDiv.classList.add(OVERLAY_CLASSNAME); // Set attributes - modalWrapper.setAttribute("role", "dialog"); + // DOTGOV + // Removes the dialog role as this causes a double readout bug with screenreaders + // modalWrapper.setAttribute("role", "dialog"); + // END DOTGOV modalWrapper.setAttribute("id", modalID); if (ariaLabelledBy) { modalWrapper.setAttribute("aria-labelledby", ariaLabelledBy); diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index b3d14839e..db6467875 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -1,4 +1,4 @@ -import { hideElement, showElement, addOrRemoveSessionBoolean } from './helpers-admin.js'; +import { hideElement, showElement, addOrRemoveSessionBoolean, announceForScreenReaders } from './helpers-admin.js'; import { handlePortfolioSelection } from './helpers-portfolio-dynamic-fields.js'; function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){ @@ -684,3 +684,33 @@ export function initDynamicDomainRequestFields(){ handleSuborgFieldsAndButtons(); } } + +export function initFilterFocusListeners() { + document.addEventListener("DOMContentLoaded", function() { + let filters = document.querySelectorAll("#changelist-filter li a"); // Get list of all filter links + let clickedFilter = false; // Used to determine if we are truly navigating away or not + + // Restore focus from localStorage + let lastClickedFilterId = localStorage.getItem("admin_filter_focus_id"); + if (lastClickedFilterId) { + let focusedElement = document.getElementById(lastClickedFilterId); + if (focusedElement) { + //Focus the element + focusedElement.setAttribute("tabindex", "0"); + focusedElement.focus({ preventScroll: true }); + + // Announce focus change for screen readers + announceForScreenReaders("Filter refocused on " + focusedElement.textContent); + localStorage.removeItem("admin_filter_focus_id"); + } + } + + // Capture clicked filter and store its ID + filters.forEach(filter => { + filter.addEventListener("click", function() { + localStorage.setItem("admin_filter_focus_id", this.id); + clickedFilter = true; // Mark that a filter was clicked + }); + }); + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-admin.js b/src/registrar/assets/src/js/getgov-admin/helpers-admin.js index 8055e29d3..5ec78f6b0 100644 --- a/src/registrar/assets/src/js/getgov-admin/helpers-admin.js +++ b/src/registrar/assets/src/js/getgov-admin/helpers-admin.js @@ -32,3 +32,22 @@ export function getParameterByName(name, url) { if (!results[2]) return ''; return decodeURIComponent(results[2].replace(/\+/g, ' ')); } + +/** + * Creates a temporary live region to announce messages for screen readers. + */ +export function announceForScreenReaders(message) { + let liveRegion = document.createElement("div"); + liveRegion.setAttribute("aria-live", "assertive"); + liveRegion.setAttribute("role", "alert"); + liveRegion.setAttribute("class", "usa-sr-only"); + document.body.appendChild(liveRegion); + + // Delay the update slightly to ensure it's recognized + setTimeout(() => { + liveRegion.textContent = message; + setTimeout(() => { + document.body.removeChild(liveRegion); + }, 1000); + }, 100); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov-admin/main.js b/src/registrar/assets/src/js/getgov-admin/main.js index 2c548a178..97ce33b93 100644 --- a/src/registrar/assets/src/js/getgov-admin/main.js +++ b/src/registrar/assets/src/js/getgov-admin/main.js @@ -10,7 +10,8 @@ import { initRejectedEmail, initApprovedDomain, initCopyRequestSummary, - initDynamicDomainRequestFields } from './domain-request-form.js'; + initDynamicDomainRequestFields, + initFilterFocusListeners } from './domain-request-form.js'; import { initDomainFormTargetBlankButtons } from './domain-form.js'; import { initDynamicPortfolioFields } from './portfolio-form.js'; import { initDynamicPortfolioPermissionFields } from './portfolio-permissions-form.js' @@ -35,6 +36,7 @@ initRejectedEmail(); initApprovedDomain(); initCopyRequestSummary(); initDynamicDomainRequestFields(); +initFilterFocusListeners(); // Domain initDomainFormTargetBlankButtons(); diff --git a/src/registrar/assets/src/js/getgov/formset-forms.js b/src/registrar/assets/src/js/getgov/formset-forms.js index 27b85212e..96d250574 100644 --- a/src/registrar/assets/src/js/getgov/formset-forms.js +++ b/src/registrar/assets/src/js/getgov/formset-forms.js @@ -292,7 +292,18 @@ export function initFormsetsForms() { // For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden, // since the form on the backend employs Django's DELETE widget. let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length; - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`); + let newFormCount = totalShownForms + 1; + // update the header + let header = newForm.querySelector('legend h3'); + header.textContent = `${formLabel} ${newFormCount}`; + header.id = `org-contact-${newFormCount}`; + // update accessibility elements on the delete buttons + let deleteDescription = newForm.querySelector('.delete-button-description'); + deleteDescription.textContent = 'Delete new contact'; + deleteDescription.id = `org-contact-${newFormCount}__name`; + let deleteButton = newForm.querySelector('button'); + deleteButton.setAttribute("aria-labelledby", header.id); + deleteButton.setAttribute("aria-describedby", deleteDescription.id); } else { // Nameservers form is cloned from index 2 which has the word optional on init, does not have the word optional // if indices 0 or 1 have been deleted diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index a077da929..c95bf2144 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -15,6 +15,7 @@ import { initDomainManagersPage } from './domain-managers.js'; import { initDomainDSData } from './domain-dsdata.js'; import { initDomainDNSSEC } from './domain-dnssec.js'; import { initFormErrorHandling } from './form-errors.js'; +import { initButtonLinks } from '../getgov-admin/button-utils.js'; initDomainValidators(); @@ -49,3 +50,5 @@ initFormErrorHandling(); initPortfolioMemberPageRadio(); initPortfolioNewMemberPageToggle(); initAddNewMemberPageListeners(); + +initButtonLinks(); diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss index 86b8779ab..a4376337d 100644 --- a/src/registrar/assets/src/sass/_theme/_accordions.scss +++ b/src/registrar/assets/src/sass/_theme/_accordions.scss @@ -25,7 +25,6 @@ // Note, width is determined by a custom width class on one of the children position: absolute; z-index: 1; - left: 0; border-radius: 4px; border: solid 1px color('base-lighter'); padding: units(2) units(2) units(3) units(2); @@ -42,6 +41,14 @@ } } +// This will work in responsive tables if we overwrite the overflow value on the table container +// Works with styles in _tables +@include at-media(desktop) { + .usa-accordion--more-actions .usa-accordion__content { + left: 0; + } +} + .usa-accordion--select .usa-accordion__content { top: 33.88px; } @@ -59,10 +66,12 @@ // This won't work on the Members table rows because that table has show-more rows // Currently, that's not an issue since that Members table is not wrapped in the // reponsive wrapper. -tr:last-of-type .usa-accordion--more-actions .usa-accordion__content { - top: auto; - bottom: -10px; - right: 30px; +@include at-media-max("desktop") { + tr:last-of-type .usa-accordion--more-actions .usa-accordion__content { + top: auto; + bottom: -10px; + right: 30px; + } } // A CSS only show-more/show-less based on usa-accordion diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index 9a1c5d12b..1442acf1f 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -226,11 +226,6 @@ abbr[title] { } } -// Boost this USWDS utility class for the accordions in the portfolio requests table -.left-auto { - left: auto!important; -} - .usa-banner__inner--widescreen { max-width: $widescreen-max-width; } diff --git a/src/registrar/assets/src/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss index 0e3118126..222f44fcc 100644 --- a/src/registrar/assets/src/sass/_theme/_tables.scss +++ b/src/registrar/assets/src/sass/_theme/_tables.scss @@ -152,3 +152,12 @@ th { .usa-table--full-borderless th { border: none !important; } + +// This is an override to overflow on certain tables (note the custom class) +// so that a popup menu can appear and starddle the edge of the table on large +// screen sizes. Works with styles in _accordions +@include at-media(desktop) { + .usa-table-container--scrollable.usa-table-container--override-overflow { + overflow-y: visible; + } +} diff --git a/src/registrar/fixtures/fixtures_requests.py b/src/registrar/fixtures/fixtures_requests.py index 6eee6438f..038c2db37 100644 --- a/src/registrar/fixtures/fixtures_requests.py +++ b/src/registrar/fixtures/fixtures_requests.py @@ -315,7 +315,7 @@ class DomainRequestFixture: cls._create_domain_requests(users) @classmethod - def _create_domain_requests(cls, users): # noqa: C901 + def _create_domain_requests(cls, users, total_requests=None): # noqa: C901 """Creates DomainRequests given a list of users.""" total_domain_requests_to_make = len(users) # 100000 @@ -323,27 +323,33 @@ class DomainRequestFixture: # number of entries. # (Prevents re-adding more entries to an already populated database, # which happens when restarting Docker src) - domain_requests_already_made = DomainRequest.objects.count() + total_existing_requests = DomainRequest.objects.count() domain_requests_to_create = [] - if domain_requests_already_made < total_domain_requests_to_make: - for user in users: - for request_data in cls.DOMAINREQUESTS: - # Prepare DomainRequest objects - try: - domain_request = DomainRequest( - creator=user, - organization_name=request_data["organization_name"], - ) - cls._set_non_foreign_key_fields(domain_request, request_data) - cls._set_foreign_key_fields(domain_request, request_data, user) - domain_requests_to_create.append(domain_request) - except Exception as e: - logger.warning(e) + if total_requests and total_requests <= total_existing_requests: + total_domain_requests_to_make = total_requests - total_existing_requests + if total_domain_requests_to_make >= 0: + DomainRequest.objects.filter( + id__in=list(DomainRequest.objects.values_list("pk", flat=True)[:total_domain_requests_to_make]) + ).delete() + if total_domain_requests_to_make == 0: + return - num_additional_requests_to_make = ( - total_domain_requests_to_make - domain_requests_already_made - len(domain_requests_to_create) - ) + for user in users: + for request_data in cls.DOMAINREQUESTS: + # Prepare DomainRequest objects + try: + domain_request = DomainRequest( + creator=user, + organization_name=request_data["organization_name"], + ) + cls._set_non_foreign_key_fields(domain_request, request_data) + cls._set_foreign_key_fields(domain_request, request_data, user) + domain_requests_to_create.append(domain_request) + except Exception as e: + logger.warning(e) + + num_additional_requests_to_make = total_domain_requests_to_make - len(domain_requests_to_create) if num_additional_requests_to_make > 0: for _ in range(num_additional_requests_to_make): random_user = random.choice(users) # nosec diff --git a/src/registrar/templates/admin/analytics.html b/src/registrar/templates/admin/analytics.html index fdebff22c..44578cd59 100644 --- a/src/registrar/templates/admin/analytics.html +++ b/src/registrar/templates/admin/analytics.html @@ -2,6 +2,10 @@ {% load static %} {% load i18n %} +{% block title %} + Registrar Analytics | Django admin +{% endblock %} + {% block content_title %}<h1>Registrar Analytics</h1>{% endblock %} {% block breadcrumbs %} diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index a8ef438f9..9b7bb5887 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -33,8 +33,8 @@ {{ tabtitle }} | {% else %} {{ title }} | - {% endif %} - {{ site_title|default:_('Django site admin') }} + {% endif %} + Django admin {% endblock %} {% block extrastyle %}{{ block.super }} diff --git a/src/registrar/templates/admin/filter.html b/src/registrar/templates/admin/filter.html new file mode 100644 index 000000000..abe3ad282 --- /dev/null +++ b/src/registrar/templates/admin/filter.html @@ -0,0 +1,13 @@ +{% comment %} Override of this file: https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/filter.html {% endcomment %} +{% load i18n %} +<details data-filter-title="{{ title }}" open> + <summary> + {% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %} + </summary> + <ul> + {% for choice in choices %} + <li {% if choice.selected %} class="selected"{% endif %}> + <a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" href="{{ choice.query_string|iriencode }}">{{ choice.display }}</a></li> + {% endfor %} + </ul> +</details> \ No newline at end of file diff --git a/src/registrar/templates/django/admin/multiple_choice_list_filter.html b/src/registrar/templates/django/admin/multiple_choice_list_filter.html index c64fa1be1..27b8d9969 100644 --- a/src/registrar/templates/django/admin/multiple_choice_list_filter.html +++ b/src/registrar/templates/django/admin/multiple_choice_list_filter.html @@ -9,16 +9,12 @@ {% for choice in choices %} {% if choice.reset %} <li{% if choice.selected %} class="selected"{% endif %}"> - <a href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a> + <a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a> </li> - {% endif %} - {% endfor %} - - {% for choice in choices %} - {% if not choice.reset %} - <li{% if choice.selected %} class="selected"{% endif %}"> + {% else %} + <li{% if choice.selected %} class="selected"{% endif %}> {% if choice.selected and choice.exclude_query_string %} - <a role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }} + <a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }} <svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use> </svg> @@ -26,9 +22,8 @@ <use xlink:href="{%static 'img/sprite.svg'%}#check"></use> </svg> </a> - {% endif %} - {% if not choice.selected and choice.include_query_string %} - <a role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }} + {% elif not choice.selected and choice.include_query_string %} + <a id="{{ title|lower|cut:' ' }}-filter-{{ choice.display|slugify }}" role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }} <svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use> </svg> @@ -38,4 +33,4 @@ {% endif %} {% endfor %} </ul> -</details> +</details> \ No newline at end of file diff --git a/src/registrar/templates/domain_org_name_address.html b/src/registrar/templates/domain_org_name_address.html index 0785b6da1..956ec084b 100644 --- a/src/registrar/templates/domain_org_name_address.html +++ b/src/registrar/templates/domain_org_name_address.html @@ -29,7 +29,10 @@ {% csrf_token %} {% if domain.domain_info.generic_org_type == 'federal' %} - {% input_with_errors form.federal_agency %} + <h4 class="margin-bottom-05">Federal Agency</h4> + <p class="margin-top-0"> + {{ domain.domain_info.federal_agency }} + </p> {% endif %} {% input_with_errors form.organization_name %} diff --git a/src/registrar/templates/domain_request_dotgov_domain.html b/src/registrar/templates/domain_request_dotgov_domain.html index 91373609d..9edba1612 100644 --- a/src/registrar/templates/domain_request_dotgov_domain.html +++ b/src/registrar/templates/domain_request_dotgov_domain.html @@ -61,7 +61,7 @@ <fieldset class="usa-fieldset margin-top-1 dotgov-domain-form" id="form-container"> <legend> - <h2>Alternative domains (optional)</h2> + <h2 id="alternative-domains-title">Alternative domains (optional)</h2> </legend> <p id="alt_domain_instructions" class="margin-top-05">Are there other domains you’d like if we can’t give @@ -79,19 +79,23 @@ {% endfor %} {% endwith %} {% endwith %} - - <button type="button" value="save" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form"> + + <div class="usa-sr-only" id="alternative-domains__add-another-alternative">Add another alternative domain</div> + <button aria-labelledby="alternative-domains-title" aria-describedby="alternative-domains__add-another-alternative" type="button" value="save" class="usa-button usa-button--unstyled usa-button--with-icon" id="add-form"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use> </svg><span class="margin-left-05">Add another alternative</span> </button> <div class="margin-bottom-3"> + <div class="usa-sr-only" id="alternative-domains__check-availability">Check domain availability</div> <button id="validate-alt-domains-availability" type="button" class="usa-button usa-button--outline" validate-for="{{ forms.1.requested_domain.auto_id }}" + aria-labelledby="alternative-domains-title" + aria-describedby="alternative-domains__check-availability" >Check availability</button> </div> diff --git a/src/registrar/templates/domain_request_other_contacts.html b/src/registrar/templates/domain_request_other_contacts.html index 65641bf4c..811a28ecf 100644 --- a/src/registrar/templates/domain_request_other_contacts.html +++ b/src/registrar/templates/domain_request_other_contacts.html @@ -31,10 +31,14 @@ <fieldset class="usa-fieldset repeatable-form padding-y-1"> <legend class="float-left-tablet"> - <h3 class="margin-top-05">Organization contact {{ forloop.counter }}</h2> + <h3 class="margin-top-05" id="org-contact-{{ forloop.counter }}">Organization contact {{ forloop.counter }}</h2> </legend> - - <button type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-top-1 text-secondary line-height-sans-5 usa-button--with-icon"> + {% if form.first_name or form.last_name %} + <span class="usa-sr-only delete-button-description" id="org-contact-{{ forloop.counter }}__name">Delete {{form.first_name.value }} {{ form.last_name.value }}</span> + {% else %} + <span class="usa-sr-only" id="org-contact-{{ forloop.counter }}__name">Delete new contact</span> + {% endif %} + <button aria-labelledby="org-contact-{{ forloop.counter }}" aria-describedby="org-contact-{{ forloop.counter }}__name" type="button" class="usa-button usa-button--unstyled display-block float-right-tablet delete-record margin-top-1 text-secondary line-height-sans-5 usa-button--with-icon"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <use xlink:href="{%static 'img/sprite.svg'%}#delete"></use> </svg>Delete diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index de4d9e712..3c96016eb 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -18,10 +18,10 @@ <h1>Manage your domains</h1> <p class="margin-top-4"> - <a href="{% url 'domain-request:start' %}" class="usa-button" + <button data-href="{% url 'domain-request:start' %}" class="usa-button use-button-as-link" > Start a new domain request - </a> + </button> </p> {% include "includes/domains_table.html" with user_domain_count=user_domain_count %} diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index 8adc0929a..5a57c1bd7 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -14,22 +14,15 @@ {% endif %} <div class="section-outlined__search section-outlined__search--widescreen {% if portfolio %}mobile:grid-col-12 desktop:grid-col-6{% endif %}"> - <section aria-label="Domain requests search component" class="margin-top-2"> + <section aria-label="Domain requests search component" id="domain-requests-search-component" class="margin-top-2"> <form class="usa-search usa-search--small" method="POST" role="search"> {% csrf_token %} - <button class="usa-button usa-button--unstyled margin-right-3 display-none" id="domain-requests__reset-search" type="button"> + <button class="usa-button usa-button--unstyled margin-right-3 display-none" id="domain-requests__reset-search" type="button" aria-labelledby="domain-requests-search-component"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <use xlink:href="{%static 'img/sprite.svg'%}#close"></use> </svg> Reset - </button> - <label id="domain-requests__search-label" class="usa-sr-only" for="domain-requests__search-field"> - {% if portfolio %} - Search by domain name or creator - {% else %} - Search by domain name - {% endif %} - </label> + </button> <input class="usa-input" id="domain-requests__search-field" @@ -40,8 +33,10 @@ {% else %} placeholder="Search by domain name" {% endif %} + aria-labelledby="domain-requests-search-component" /> - <button class="usa-button" type="submit" id="domain-requests__search-field-submit" aria-labelledby="domain-requests__search-label"> + <div class="usa-sr-only" id="domain-requests-search-button__description">Click to search</div> + <button class="usa-button" type="submit" id="domain-requests__search-field-submit" aria-labelledby="domain-requests-search-component" aria-describedby="domain-requests-search-button__description"> <img src="{% static 'img/usa-icons-bg/search--white.svg' %}" class="usa-search__submit-icon" @@ -163,7 +158,7 @@ </div> {% endif %} - <div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domain-requests__table-wrapper"> + <div class="display-none usa-table-container--scrollable usa-table-container--override-overflow margin-top-0" tabindex="0" id="domain-requests__table-wrapper"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked"> <caption class="sr-only">Your domain requests</caption> <thead> diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index 3cf04a830..567bddd2a 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -34,24 +34,25 @@ <span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span> {% endif %} <div class="section-outlined__search section-outlined__search--widescreen {% if portfolio %}mobile:grid-col-12 desktop:grid-col-6{% endif %}"> - <section aria-label="Domains search component" class="margin-top-2"> + <section aria-label="Domains search component" class="margin-top-2" id="domains-search-component"> <form class="usa-search usa-search--small" method="POST" role="search"> {% csrf_token %} - <button class="usa-button usa-button--unstyled margin-right-3 display-none" id="domains__reset-search" type="button"> + <button class="usa-button usa-button--unstyled margin-right-3 display-none" id="domains__reset-search" type="button" aria-labelledby="domains-search-component"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <use xlink:href="{%static 'img/sprite.svg'%}#close"></use> </svg> Reset </button> - <label id="domains__search-label" class="usa-sr-only" for="domains__search-field">Search by domain name</label> <input class="usa-input" id="domains__search-field" type="search" name="domains-search" placeholder="Search by domain name" + aria-labelledby="domains-search-component" /> - <button class="usa-button" type="submit" id="domains__search-field-submit" aria-labelledby="domains__search-label"> + <div class="usa-sr-only" id="domains-search-button__description">Click to search</div> + <button class="usa-button" type="submit" id="domains__search-field-submit" aria-labelledby="domains-search-component" aria-describedby="domains-search-button__description"> <img src="{% static 'img/usa-icons-bg/search--white.svg' %}" class="usa-search__submit-icon" @@ -63,12 +64,13 @@ </div> {% if user_domain_count and user_domain_count > 0 %} <div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}"> - <section aria-label="Domains report component" class="margin-top-205"> - <a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right"> + <section aria-label="Domains report component" class="margin-top-205" id="domains-report-component"> + <div class="usa-sr-only" id="domains-export-button__description">Click to export as csv</div> + <button data-href="{% url 'export_data_type_user' %}" class="use-button-as-link usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" aria-labelledby="domains-report-component" aria-describedby="domains-export-button__description"> <svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> </svg>Export as CSV - </a> + </button> </section> </div> {% endif %} @@ -198,7 +200,7 @@ </svg> </button> </div> - <div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domains__table-wrapper"> + <div class="display-none usa-table-container--scrollable usa-table-container--override-overflow margin-top-0" tabindex="0" id="domains__table-wrapper"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked"> <caption class="sr-only">Your registered domains</caption> <thead> diff --git a/src/registrar/templates/includes/form_errors.html b/src/registrar/templates/includes/form_errors.html index 52c82aaf0..816704ed5 100644 --- a/src/registrar/templates/includes/form_errors.html +++ b/src/registrar/templates/includes/form_errors.html @@ -1,16 +1,18 @@ {% if form.errors %} <div id="form-errors"> {% for error in form.non_field_errors %} - <div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2" role="alert"> + <div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2" role="alert" tabindex="0"> <div class="usa-alert__body"> - {{ error|escape }} + <span class="usa-sr-only">Error:</span> + {{ error|escape }} </div> </div> {% endfor %} {% for field in form %} {% for error in field.errors %} - <div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2"> + <div class="usa-alert usa-alert--error usa-alert--slim margin-bottom-2" tabindex="0"> <div class="usa-alert__body"> + <span class="usa-sr-only">Error:</span> {{ error|escape }} </div> </div> diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html index be1715f30..528f9a5dc 100644 --- a/src/registrar/templates/includes/members_table.html +++ b/src/registrar/templates/includes/members_table.html @@ -9,24 +9,25 @@ <div class="section-outlined__header margin-bottom-3 grid-row"> <!-- ---------- SEARCH ---------- --> <div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-6 section-outlined__search--widescreen"> - <section aria-label="Members search component" class="margin-top-2"> + <section aria-label="Members search component" class="margin-top-2" id="members-search-component"> <form class="usa-search usa-search--small" method="POST" role="search"> {% csrf_token %} - <button class="usa-button usa-button--unstyled margin-right-3 display-none" id="members__reset-search" type="button"> + <button class="usa-button usa-button--unstyled margin-right-3 display-none" id="members__reset-search" type="button" aria-labelledby="members-search-component"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24"> <use xlink:href="{%static 'img/sprite.svg'%}#close"></use> </svg> Reset </button> - <label class="usa-sr-only" for="members__search-field">Search by member name</label> <input class="usa-input" id="members__search-field" type="search" name="members-search" placeholder="Search by member name" + aria-labelledby="members-search-component" /> - <button class="usa-button" type="submit" id="members__search-field-submit"> + <div class="usa-sr-only" id="members-search-button__description">Click to search</div> + <button class="usa-button" type="submit" id="members__search-field-submit" aria-labelledby="members-search-component" aria-describedby="members-search-button__description"> <img src="{% static 'img/usa-icons-bg/search--white.svg' %}" class="usa-search__submit-icon" @@ -37,12 +38,13 @@ </section> </div> <div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}"> - <section aria-label="Domains report component" class="margin-top-205"> - <a href="{% url 'export_members_portfolio' %}" class="usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right"> + <section aria-label="Members report component" class="margin-top-205" id="members-report-component"> + <div class="usa-sr-only" id="members-export-button__description">Click to export as csv</div> + <button href="{% url 'export_members_portfolio' %}" class="use-button-as-link usa-button usa-button--unstyled usa-button--with-icon usa-button--justify-right" aria-labelledby="members-report-component" aria-describedby="members-export-button__description"> <svg class="usa-icon usa-icon--large" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use> </svg>Export as CSV - </a> + </button> </section> </div> </div> diff --git a/src/registrar/templates/portfolio_requests.html b/src/registrar/templates/portfolio_requests.html index 58fbde10c..4f366d0c6 100644 --- a/src/registrar/templates/portfolio_requests.html +++ b/src/registrar/templates/portfolio_requests.html @@ -26,10 +26,10 @@ <div class="mobile:grid-col-12 tablet:grid-col-6"> <p class="float-right-tablet tablet:margin-y-0"> - <a href="{% url 'domain-request:start' %}" class="usa-button" + <button data-href="{% url 'domain-request:start' %}" class="usa-button use-button-as-link" > Start a new domain request - </a> + </button> </p> </div> {% else %} diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 4eda6a30d..211e90a2b 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -219,12 +219,12 @@ class TestDomainInvitationAdmin(WebTest): # Assert that the filters are added self.assertContains(response, "invited", count=4) self.assertContains(response, "Invited", count=2) - self.assertContains(response, "retrieved", count=2) + self.assertContains(response, "retrieved", count=3) self.assertContains(response, "Retrieved", count=2) # Check for the HTML context specificially - invited_html = '<a href="?status__exact=invited">Invited</a>' - retrieved_html = '<a href="?status__exact=retrieved">Retrieved</a>' + invited_html = '<a id="status-filter-invited" href="?status__exact=invited">Invited</a>' + retrieved_html = '<a id="status-filter-retrieved" href="?status__exact=retrieved">Retrieved</a>' self.assertContains(response, invited_html, count=1) self.assertContains(response, retrieved_html, count=1) @@ -1271,14 +1271,14 @@ class TestPortfolioInvitationAdmin(TestCase): ) # Assert that the filters are added - self.assertContains(response, "invited", count=4) + self.assertContains(response, "invited", count=5) self.assertContains(response, "Invited", count=2) - self.assertContains(response, "retrieved", count=2) + self.assertContains(response, "retrieved", count=3) self.assertContains(response, "Retrieved", count=2) # Check for the HTML context specificially - invited_html = '<a href="?status__exact=invited">Invited</a>' - retrieved_html = '<a href="?status__exact=retrieved">Retrieved</a>' + invited_html = '<a id="status-filter-invited" href="?status__exact=invited">Invited</a>' + retrieved_html = '<a id="status-filter-retrieved" href="?status__exact=retrieved">Retrieved</a>' self.assertContains(response, invited_html, count=1) self.assertContains(response, retrieved_html, count=1) diff --git a/src/registrar/tests/test_reports.py b/src/registrar/tests/test_reports.py index 18c98807d..9ec3bd0d3 100644 --- a/src/registrar/tests/test_reports.py +++ b/src/registrar/tests/test_reports.py @@ -888,8 +888,8 @@ class MemberExportTest(MockDbForIndividualTests, MockEppLib): csv_content = csv_file.read() expected_content = ( # Header - "Email,Organization admin,Invited by,Joined date,Last active,Domain requests," - "Member management,Domain management,Number of domains,Domains\n" + "Email,Member access,Invited by,Joined date,Last active,Domain requests," + "Members,Domains,Number domains assigned,Domain assignments\n" # Content "big_lebowski@dude.co,False,help@get.gov,2022-04-01,Invalid date,None," "Viewer,True,1,cdomain1.gov\n" diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 220daf54d..940fe29bf 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -712,7 +712,7 @@ class TestDomainDetailDomainRenewal(TestDomainOverview): self.assertRedirects(response, reverse("domain", kwargs={"domain_pk": self.domain_with_ip.id})) # Check for the updated expiration - formatted_new_expiration_date = self.expiration_date_one_year_out().strftime("%b. %-d, %Y") + formatted_new_expiration_date = self.expiration_date_one_year_out().strftime("%B %-d, %Y") redirect_response = self.client.get( reverse("domain", kwargs={"domain_pk": self.domain_with_ip.id}), follow=True ) @@ -2088,62 +2088,6 @@ class TestDomainOrganization(TestDomainOverview): # Check for the value we want to update self.assertContains(success_result_page, "Faketown") - @less_console_noise_decorator - def test_domain_org_name_address_form_federal(self): - """ - Submitting a change to federal_agency is blocked for federal domains - """ - - fed_org_type = DomainInformation.OrganizationChoices.FEDERAL - self.domain_information.generic_org_type = fed_org_type - self.domain_information.save() - try: - federal_agency, _ = FederalAgency.objects.get_or_create(agency="AMTRAK") - self.domain_information.federal_agency = federal_agency - self.domain_information.save() - except ValueError as err: - self.fail(f"A ValueError was caught during the test: {err}") - - self.assertEqual(self.domain_information.generic_org_type, fed_org_type) - - org_name_page = self.app.get(reverse("domain-org-name-address", kwargs={"domain_pk": self.domain.id})) - - form = org_name_page.forms[0] - # Check the value of the input field - agency_input = form.fields["federal_agency"][0] - self.assertEqual(agency_input.value, str(federal_agency.id)) - - # Check if the input field is disabled - self.assertTrue("disabled" in agency_input.attrs) - self.assertEqual(agency_input.attrs.get("disabled"), "") - - session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] - - org_name_page.form["federal_agency"] = FederalAgency.objects.filter(agency="Department of State").get().id - org_name_page.form["city"] = "Faketown" - - self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) - - # Make the change. The agency should be unchanged, but city should be modifiable. - success_result_page = org_name_page.form.submit() - self.assertEqual(success_result_page.status_code, 200) - - # Check that the agency has not changed - self.assertEqual(self.domain_information.federal_agency.agency, "AMTRAK") - - # Do another check on the form itself - form = success_result_page.forms[0] - # Check the value of the input field - organization_name_input = form.fields["federal_agency"][0] - self.assertEqual(organization_name_input.value, str(federal_agency.id)) - - # Check if the input field is disabled - self.assertTrue("disabled" in organization_name_input.attrs) - self.assertEqual(organization_name_input.attrs.get("disabled"), "") - - # Check for the value we want to update - self.assertContains(success_result_page, "Faketown") - @less_console_noise_decorator def test_federal_agency_submit_blocked(self): """ diff --git a/src/registrar/utility/csv_export.py b/src/registrar/utility/csv_export.py index 1bb53a7a3..fad58b2e2 100644 --- a/src/registrar/utility/csv_export.py +++ b/src/registrar/utility/csv_export.py @@ -38,10 +38,15 @@ from django.contrib.admin.models import LogEntry, ADDITION from django.contrib.contenttypes.models import ContentType from registrar.models.utility.generic_helper import convert_queryset_to_dict from registrar.models.utility.orm_helper import ArrayRemoveNull -from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices from registrar.templatetags.custom_filters import get_region from registrar.utility.constants import BranchChoices from registrar.utility.enums import DefaultEmail, DefaultUserValues +from registrar.models.utility.portfolio_helper import ( + get_role_display, + get_domain_requests_display, + get_domains_display, + get_members_display, +) logger = logging.getLogger(__name__) @@ -479,15 +484,15 @@ class MemberExport(BaseExport): """ return [ "Email", - "Organization admin", + "Member access", "Invited by", "Joined date", "Last active", "Domain requests", - "Member management", - "Domain management", - "Number of domains", + "Members", "Domains", + "Number domains assigned", + "Domain assignments", ] @classmethod @@ -503,15 +508,15 @@ class MemberExport(BaseExport): length_user_managed_domains = len(user_managed_domains) FIELDS = { "Email": model.get("email_display"), - "Organization admin": bool(UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles), + "Member access": get_role_display(roles), "Invited by": model.get("invited_by"), "Joined date": model.get("joined_date"), "Last active": model.get("last_active"), - "Domain requests": UserPortfolioPermission.get_domain_request_permission_display(roles, permissions), - "Member management": UserPortfolioPermission.get_member_permission_display(roles, permissions), - "Domain management": bool(length_user_managed_domains > 0), - "Number of domains": length_user_managed_domains, - "Domains": ",".join(user_managed_domains), + "Domain requests": f"{get_domain_requests_display(roles, permissions)}", + "Members": f"{get_members_display(roles, permissions)}", + "Domains": f"{get_domains_display(roles, permissions)}", + "Number domains assigned": length_user_managed_domains, + "Domain assignments": ", ".join(user_managed_domains), } return [FIELDS.get(column, "") for column in columns] diff --git a/src/temp-remove b/src/temp-remove deleted file mode 100644 index e69de29bb..000000000