diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index fd9e31b91..642e9dc30 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -70,6 +70,6 @@ jobs:
- name: run pa11y
working-directory: ./src
run: |
- sleep 10;
+ sleep 20;
npm i -g pa11y-ci
pa11y-ci
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 9abb909d2..bb256f742 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -33,6 +33,7 @@ from django.contrib.auth.forms import UserChangeForm, UsernameField
from django_admin_multiple_choice_list_filter.list_filters import MultipleChoiceListFilter
from import_export import resources
from import_export.admin import ImportExportModelAdmin
+from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _
@@ -1818,10 +1819,93 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return response
def change_view(self, request, object_id, form_url="", extra_context=None):
+ """Display restricted warning,
+ Setup the auditlog trail and pass it in extra context."""
obj = self.get_object(request, object_id)
self.display_restricted_warning(request, obj)
+
+ # Initialize variables for tracking status changes and filtered entries
+ filtered_audit_log_entries = []
+
+ try:
+ # Retrieve and order audit log entries by timestamp in descending order
+ audit_log_entries = LogEntry.objects.filter(object_id=object_id).order_by("-timestamp")
+
+ # Process each log entry to filter based on the change criteria
+ for log_entry in audit_log_entries:
+ entry = self.process_log_entry(log_entry)
+ if entry:
+ filtered_audit_log_entries.append(entry)
+
+ except ObjectDoesNotExist as e:
+ logger.error(f"Object with object_id {object_id} does not exist: {e}")
+ except Exception as e:
+ logger.error(f"An error occurred during change_view: {e}")
+
+ # Initialize extra_context and add filtered entries
+ extra_context = extra_context or {}
+ extra_context["filtered_audit_log_entries"] = filtered_audit_log_entries
+
+ # Call the superclass method with updated extra_context
return super().change_view(request, object_id, form_url, extra_context)
+ def process_log_entry(self, log_entry):
+ """Process a log entry and return filtered entry dictionary if applicable."""
+ changes = log_entry.changes
+ status_changed = "status" in changes
+ rejection_reason_changed = "rejection_reason" in changes
+ action_needed_reason_changed = "action_needed_reason" in changes
+
+ # Check if the log entry meets the filtering criteria
+ if status_changed or (not status_changed and (rejection_reason_changed or action_needed_reason_changed)):
+ entry = {}
+
+ # Handle status change
+ if status_changed:
+ _, status_value = changes.get("status")
+ if status_value:
+ entry["status"] = DomainRequest.DomainRequestStatus.get_status_label(status_value)
+
+ # Handle rejection reason change
+ if rejection_reason_changed:
+ _, rejection_reason_value = changes.get("rejection_reason")
+ if rejection_reason_value:
+ entry["rejection_reason"] = (
+ ""
+ if rejection_reason_value == "None"
+ else DomainRequest.RejectionReasons.get_rejection_reason_label(rejection_reason_value)
+ )
+ # Handle case where rejection reason changed but not status
+ if not status_changed:
+ entry["status"] = DomainRequest.DomainRequestStatus.get_status_label(
+ DomainRequest.DomainRequestStatus.REJECTED
+ )
+
+ # Handle action needed reason change
+ if action_needed_reason_changed:
+ _, action_needed_reason_value = changes.get("action_needed_reason")
+ if action_needed_reason_value:
+ entry["action_needed_reason"] = (
+ ""
+ if action_needed_reason_value == "None"
+ else DomainRequest.ActionNeededReasons.get_action_needed_reason_label(
+ action_needed_reason_value
+ )
+ )
+ # Handle case where action needed reason changed but not status
+ if not status_changed:
+ entry["status"] = DomainRequest.DomainRequestStatus.get_status_label(
+ DomainRequest.DomainRequestStatus.ACTION_NEEDED
+ )
+
+ # Add actor and timestamp information
+ entry["actor"] = log_entry.actor
+ entry["timestamp"] = log_entry.timestamp
+
+ return entry
+
+ return None
+
class TransitionDomainAdmin(ListHeaderAdmin):
"""Custom transition domain admin class."""
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 0f3d8b2ad..9c75d9856 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -137,6 +137,47 @@ function openInNewTab(el, removeAttribute = false){
prepareDjangoAdmin();
})();
+
+/** An IIFE for the "Assign to me" button under the investigator field in DomainRequests.
+** This field uses the "select2" selector, rather than the default.
+** To perform data operations on this - we need to use jQuery rather than vanilla js.
+*/
+(function (){
+ let selector = django.jQuery("#id_investigator")
+ let assignSelfButton = document.querySelector("#investigator__assign_self");
+ if (!selector || !assignSelfButton) {
+ return;
+ }
+
+ let currentUserId = assignSelfButton.getAttribute("data-user-id");
+ let currentUserName = assignSelfButton.getAttribute("data-user-name");
+ if (!currentUserId || !currentUserName){
+ console.error("Could not assign current user: no values found.")
+ return;
+ }
+
+ // Hook a click listener to the "Assign to me" button.
+ // Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists
+ assignSelfButton.addEventListener("click", function() {
+ if (selector.find(`option[value='${currentUserId}']`).length) {
+ // Select the value that is associated with the current user.
+ selector.val(currentUserId).trigger("change");
+ } else {
+ // Create a DOM Option that matches the desired user. Then append it and select it.
+ let userOption = new Option(currentUserName, currentUserId, true, true);
+ selector.append(userOption).trigger("change");
+ }
+ });
+
+ // Listen to any change events, and hide the parent container if investigator has a value.
+ selector.on('change', function() {
+ // The parent container has display type flex.
+ assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex";
+ });
+
+
+
+})();
/** An IIFE for pages in DjangoAdmin that use a clipboard button
*/
(function (){
diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 0d594b315..cc11f1336 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -17,6 +17,22 @@ var SUCCESS = "success";
// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>>
// Helper functions.
+/**
+ * Hide element
+ *
+*/
+const hideElement = (element) => {
+ element.classList.add('display-none');
+};
+
+/**
+ * Show element
+ *
+*/
+const showElement = (element) => {
+ element.classList.remove('display-none');
+};
+
/** Makes an element invisible. */
function makeHidden(el) {
el.style.position = "absolute";
@@ -918,8 +934,9 @@ function ScrollToElement(attributeName, attributeValue) {
* @param {boolean} hasPrevious - Whether there is a page before the current page.
* @param {boolean} hasNext - Whether there is a page after the current page.
* @param {number} totalItems - The total number of items.
+ * @param {string} searchTerm - The search term
*/
-function updatePagination(itemName, paginationSelector, counterSelector, headerAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems) {
+function updatePagination(itemName, paginationSelector, counterSelector, headerAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) {
const paginationContainer = document.querySelector(paginationSelector);
const paginationCounter = document.querySelector(counterSelector);
const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`);
@@ -932,7 +949,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
// Counter should only be displayed if there is more than 1 item
paginationContainer.classList.toggle('display-none', totalItems < 1);
- paginationCounter.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}`;
+ paginationCounter.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}${searchTerm ? ' for ' + '"' + searchTerm + '"' : ''}`;
if (hasPrevious) {
const prevPageItem = document.createElement('li');
@@ -1018,6 +1035,47 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
}
}
+/**
+ * A helper that toggles content/ no content/ no search results
+ *
+*/
+const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm) => {
+ const { unfiltered_total, total } = data;
+
+ if (searchTermHolder)
+ searchTermHolder.innerHTML = '';
+
+ if (unfiltered_total) {
+ if (total) {
+ showElement(dataWrapper);
+ hideElement(noSearchResultsWrapper);
+ hideElement(noDataWrapper);
+ } else {
+ if (searchTermHolder)
+ searchTermHolder.innerHTML = currentSearchTerm;
+ hideElement(dataWrapper);
+ showElement(noSearchResultsWrapper);
+ hideElement(noDataWrapper);
+ }
+ } else {
+ hideElement(dataWrapper);
+ hideElement(noSearchResultsWrapper);
+ showElement(noDataWrapper);
+ }
+};
+
+/**
+ * A helper that resets sortable table headers
+ *
+*/
+const unsetHeader = (header) => {
+ header.removeAttribute('aria-sort');
+ let headerName = header.innerText;
+ const headerLabel = `${headerName}, sortable column, currently unsorted"`;
+ const headerButtonLabel = `Click to sort by ascending order.`;
+ header.setAttribute("aria-label", headerLabel);
+ header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel);
+};
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
@@ -1025,13 +1083,21 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
*
*/
document.addEventListener('DOMContentLoaded', function() {
- let domainsWrapper = document.querySelector('.domains-wrapper');
+ const domainsWrapper = document.querySelector('.domains__table-wrapper');
if (domainsWrapper) {
let currentSortBy = 'id';
let currentOrder = 'asc';
- let noDomainsWrapper = document.querySelector('.no-domains-wrapper');
+ const noDomainsWrapper = document.querySelector('.domains__no-data');
+ const noSearchResultsWrapper = document.querySelector('.domains__no-search-results');
let hasLoaded = false;
+ let currentSearchTerm = ''
+ const domainsSearchInput = document.getElementById('domains__search-field');
+ const domainsSearchSubmit = document.getElementById('domains__search-field-submit');
+ const tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]');
+ const tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region');
+ const searchTermHolder = document.querySelector('.domains__search-term');
+ const resetButton = document.querySelector('.domains__reset-button');
/**
* Loads rows in the domains list, as well as updates pagination around the domains list
@@ -1040,10 +1106,11 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc}
* @param {*} loaded - control for the scrollToElement functionality
+ * @param {*} searchTerm - the search term
*/
- function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded) {
+ function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
//fetch json of page of domains, given page # and sort
- fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}`)
+ fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
.then(response => response.json())
.then(data => {
if (data.error) {
@@ -1051,23 +1118,17 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
- // handle the display of proper messaging in the event that no domains exist in the list
- 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');
- }
+ // handle the display of proper messaging in the event that no domains exist in the list or search returns no results
+ updateDisplay(data, domainsWrapper, noDomainsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm);
// identify the DOM element where the domain list will be inserted into the DOM
- const domainList = document.querySelector('.dotgov-table__registered-domains tbody');
+ const domainList = document.querySelector('.domains__table tbody');
domainList.innerHTML = '';
data.domains.forEach(domain => {
const options = { year: 'numeric', month: 'short', day: 'numeric' };
const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null;
- const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : null;
+ const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url;
@@ -1106,9 +1167,10 @@ document.addEventListener('DOMContentLoaded', function() {
});
// initialize tool tips immediately after the associated DOM elements are added
initializeTooltips();
+
+ // Do not scroll on first page load
if (loaded)
ScrollToElement('id', 'domains-header');
-
hasLoaded = true;
// update pagination
@@ -1122,18 +1184,18 @@ document.addEventListener('DOMContentLoaded', function() {
data.num_pages,
data.has_previous,
data.has_next,
- data.total
+ data.total,
+ currentSearchTerm
);
currentSortBy = sortBy;
currentOrder = order;
+ currentSearchTerm = searchTerm;
})
.catch(error => console.error('Error fetching domains:', error));
}
-
-
// Add event listeners to table headers for sorting
- document.querySelectorAll('.dotgov-table__registered-domains th[data-sortable]').forEach(header => {
+ tableHeaders.forEach(header => {
header.addEventListener('click', function() {
const sortBy = this.getAttribute('data-sortable');
let order = 'asc';
@@ -1147,6 +1209,43 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
+ domainsSearchSubmit.addEventListener('click', function(e) {
+ e.preventDefault();
+ currentSearchTerm = domainsSearchInput.value;
+ // If the search is blank, we match the resetSearch functionality
+ if (currentSearchTerm) {
+ showElement(resetButton);
+ } else {
+ hideElement(resetButton);
+ }
+ loadDomains(1, 'id', 'asc');
+ resetHeaders();
+ })
+
+ // Reset UI and accessibility
+ function resetHeaders() {
+ tableHeaders.forEach(header => {
+ // Unset sort UI in headers
+ unsetHeader(header);
+ });
+ // Reset the announcement region
+ tableAnnouncementRegion.innerHTML = '';
+ }
+
+ function resetSearch() {
+ domainsSearchInput.value = '';
+ currentSearchTerm = '';
+ hideElement(resetButton);
+ loadDomains(1, 'id', 'asc', hasLoaded, '');
+ resetHeaders();
+ }
+
+ if (resetButton) {
+ resetButton.addEventListener('click', function() {
+ resetSearch();
+ });
+ }
+
// Load the first page initially
loadDomains(1);
}
@@ -1157,25 +1256,71 @@ const utcDateString = (dateString) => {
const utcYear = date.getUTCFullYear();
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
const utcDay = date.getUTCDate().toString().padStart(2, '0');
- const utcHours = date.getUTCHours().toString().padStart(2, '0');
+ let utcHours = date.getUTCHours();
const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0');
-
- return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} UTC`;
+
+ const ampm = utcHours >= 12 ? 'PM' : 'AM';
+ utcHours = utcHours % 12 || 12; // Convert to 12-hour format, '0' hours should be '12'
+
+ return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} ${ampm} UTC`;
};
/**
- * An IIFE that listens for DOM Content to be loaded, then executes. This function
+ * An IIFE that listens for DOM Content to be loaded, then executes. This function
* initializes the domain requests list and associated functionality on the home page of the app.
*
*/
document.addEventListener('DOMContentLoaded', function() {
- let domainRequestsWrapper = document.querySelector('.domain-requests-wrapper');
+ const domainRequestsSectionWrapper = document.querySelector('.domain-requests');
+ const domainRequestsWrapper = document.querySelector('.domain-requests__table-wrapper');
if (domainRequestsWrapper) {
let currentSortBy = 'id';
let currentOrder = 'asc';
- let noDomainRequestsWrapper = document.querySelector('.no-domain-requests-wrapper');
+ const noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data');
+ const noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results');
let hasLoaded = false;
+ let currentSearchTerm = ''
+ const domainRequestsSearchInput = document.getElementById('domain-requests__search-field');
+ const domainRequestsSearchSubmit = document.getElementById('domain-requests__search-field-submit');
+ const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
+ const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
+ const searchTermHolder = document.querySelector('.domain-requests__search-term');
+ const resetButton = document.querySelector('.domain-requests__reset-button');
+
+ /**
+ * Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input.
+ * @param {*} domainRequestPk - the identifier for the request that we're deleting
+ * @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page
+ */
+ function deleteDomainRequest(domainRequestPk,pageToDisplay) {
+ // Get csrf token
+ const csrfToken = getCsrfToken();
+ // Create FormData object and append the CSRF token
+ const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}&delete-domain-request=`;
+
+ fetch(`/domain-request/${domainRequestPk}/delete`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'X-CSRFToken': csrfToken,
+ },
+ body: formData
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ // Update data and UI
+ loadDomainRequests(pageToDisplay, currentSortBy, currentOrder, hasLoaded, currentSearchTerm);
+ })
+ .catch(error => console.error('Error fetching domain requests:', error));
+ }
+
+ // Helper function to get the CSRF token from the cookie
+ function getCsrfToken() {
+ return document.querySelector('input[name="csrfmiddlewaretoken"]').value;
+ }
/**
* Loads rows in the domain requests list, as well as updates pagination around the domain requests list
@@ -1184,10 +1329,11 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc}
* @param {*} loaded - control for the scrollToElement functionality
+ * @param {*} searchTerm - the search term
*/
- function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded) {
+ function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) {
//fetch json of page of domain requests, given page # and sort
- fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}`)
+ fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
.then(response => response.json())
.then(data => {
if (data.error) {
@@ -1195,41 +1341,138 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
- // handle the display of proper messaging in the event that no domain requests exist in the list
- 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');
- }
+ // handle the display of proper messaging in the event that no requests exist in the list or search returns no results
+ updateDisplay(data, domainRequestsWrapper, noDomainRequestsWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm);
// identify the DOM element where the domain request list will be inserted into the DOM
- const tbody = document.querySelector('.dotgov-table__domain-requests tbody');
+ const tbody = document.querySelector('.domain-requests__table tbody');
tbody.innerHTML = '';
// remove any existing modal elements from the DOM so they can be properly re-initialized
// after the DOM content changes and there are new delete modal buttons added
unloadModals();
+
+ let needsDeleteColumn = false;
+
+ needsDeleteColumn = data.domain_requests.some(request => request.is_deletable);
+
+ // Remove existing delete th and td if they exist
+ let existingDeleteTh = document.querySelector('.delete-header');
+ if (!needsDeleteColumn) {
+ if (existingDeleteTh)
+ existingDeleteTh.remove();
+ } else {
+ if (!existingDeleteTh) {
+ const delheader = document.createElement('th');
+ delheader.setAttribute('scope', 'col');
+ delheader.setAttribute('role', 'columnheader');
+ delheader.setAttribute('class', 'delete-header');
+ delheader.innerHTML = `
+ Delete Action`;
+ let tableHeaderRow = document.querySelector('.domain-requests__table thead tr');
+ tableHeaderRow.appendChild(delheader);
+ }
+ }
+
data.domain_requests.forEach(request => {
const options = { year: 'numeric', month: 'short', day: 'numeric' };
const domainName = request.requested_domain ? request.requested_domain : `New domain request
(${utcDateString(request.created_at)})`;
const actionUrl = request.action_url;
const actionLabel = request.action_label;
const submissionDate = request.submission_date ? new Date(request.submission_date).toLocaleDateString('en-US', options) : `Not submitted`;
- const deleteButton = request.is_deletable ? `
-
- Delete ${domainName}
- ` : '';
+
+ // Even if the request is not deletable, we may need this empty string for the td if the deletable column is displayed
+ let modalTrigger = '';
+
+ // If the request is deletable, create modal body and insert it
+ if (request.is_deletable) {
+ let modalHeading = '';
+ let modalDescription = '';
+
+ if (request.requested_domain) {
+ modalHeading = `Are you sure you want to delete ${request.requested_domain}?`;
+ modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.';
+ } else {
+ if (request.created_at) {
+ modalHeading = 'Are you sure you want to delete this domain request?';
+ modalDescription = `This will remove the domain request (created ${utcDateString(request.created_at)}) from the .gov registrar. This action cannot be undone`;
+ } else {
+ modalHeading = 'Are you sure you want to delete New domain request?';
+ modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.';
+ }
+ }
+
+ modalTrigger = `
+
+ Delete ${domainName}
+ `
+
+ const modalSubmit = `
+
+ `
+
+ const modal = document.createElement('div');
+ modal.setAttribute('class', 'usa-modal');
+ modal.setAttribute('id', `toggle-delete-domain-alert-${request.id}`);
+ modal.setAttribute('aria-labelledby', 'Are you sure you want to continue?');
+ modal.setAttribute('aria-describedby', 'Domain will be removed');
+ modal.setAttribute('data-force-action', '');
+
+ modal.innerHTML = `
+
+
+
+ ${modalHeading}
+
+
+
+ ${modalDescription}
+
+
+
+
+
+
+ `
+
+ domainRequestsSectionWrapper.appendChild(modal);
+ }
const row = document.createElement('tr');
row.innerHTML = `
@@ -1250,15 +1493,36 @@ document.addEventListener('DOMContentLoaded', function() {
${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'}
- ${deleteButton} |
+ ${needsDeleteColumn ? ''+modalTrigger+' | ' : ''}
`;
tbody.appendChild(row);
});
+
// initialize modals immediately after the DOM content is updated
initializeModals();
+
+ // Now the DOM and modals are ready, add listeners to the submit buttons
+ const modals = document.querySelectorAll('.usa-modal__content');
+
+ modals.forEach(modal => {
+ const submitButton = modal.querySelector('.usa-modal__submit');
+ const closeButton = modal.querySelector('.usa-modal__close');
+ submitButton.addEventListener('click', function() {
+ pk = submitButton.getAttribute('data-pk');
+ // Close the modal to remove the USWDS UI local classes
+ closeButton.click();
+ // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page
+ let pageToDisplay = data.page;
+ if (data.total == 1 && data.unfiltered_total > 1) {
+ pageToDisplay--;
+ }
+ deleteDomainRequest(pk, pageToDisplay);
+ });
+ });
+
+ // Do not scroll on first page load
if (loaded)
ScrollToElement('id', 'domain-requests-header');
-
hasLoaded = true;
// update the pagination after the domain requests list is updated
@@ -1272,16 +1536,18 @@ document.addEventListener('DOMContentLoaded', function() {
data.num_pages,
data.has_previous,
data.has_next,
- data.total
+ data.total,
+ currentSearchTerm
);
currentSortBy = sortBy;
currentOrder = order;
+ currentSearchTerm = searchTerm;
})
.catch(error => console.error('Error fetching domain requests:', error));
}
// Add event listeners to table headers for sorting
- document.querySelectorAll('.dotgov-table__domain-requests th[data-sortable]').forEach(header => {
+ tableHeaders.forEach(header => {
header.addEventListener('click', function() {
const sortBy = this.getAttribute('data-sortable');
let order = 'asc';
@@ -1294,6 +1560,43 @@ document.addEventListener('DOMContentLoaded', function() {
});
});
+ domainRequestsSearchSubmit.addEventListener('click', function(e) {
+ e.preventDefault();
+ currentSearchTerm = domainRequestsSearchInput.value;
+ // If the search is blank, we match the resetSearch functionality
+ if (currentSearchTerm) {
+ showElement(resetButton);
+ } else {
+ hideElement(resetButton);
+ }
+ loadDomainRequests(1, 'id', 'asc');
+ resetHeaders();
+ })
+
+ // Reset UI and accessibility
+ function resetHeaders() {
+ tableHeaders.forEach(header => {
+ // unset sort UI in headers
+ unsetHeader(header);
+ });
+ // Reset the announcement region
+ tableAnnouncementRegion.innerHTML = '';
+ }
+
+ function resetSearch() {
+ domainRequestsSearchInput.value = '';
+ currentSearchTerm = '';
+ hideElement(resetButton);
+ loadDomainRequests(1, 'id', 'asc', hasLoaded, '');
+ resetHeaders();
+ }
+
+ if (resetButton) {
+ resetButton.addEventListener('click', function() {
+ resetSearch();
+ });
+ }
+
// Load the first page initially
loadDomainRequests(1);
}
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index 701b239ca..360055d91 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -782,3 +782,7 @@ div.dja__model-description{
padding: 6px 8px 10px 8px;
}
}
+
+.usa-button--dja-link-color {
+ color: var(--link-fg);
+}
diff --git a/src/registrar/assets/sass/_theme/_tables.scss b/src/registrar/assets/sass/_theme/_tables.scss
index 26d90d291..a5eb5a4e0 100644
--- a/src/registrar/assets/sass/_theme/_tables.scss
+++ b/src/registrar/assets/sass/_theme/_tables.scss
@@ -98,7 +98,7 @@
}
}
@media (min-width: 1040px){
- .dotgov-table__domain-requests {
+ .domain-requests__table {
th:nth-of-type(1) {
width: 200px;
}
@@ -122,7 +122,7 @@
}
@media (min-width: 1040px){
- .dotgov-table__registered-domains {
+ .domains__table {
th:nth-of-type(1) {
width: 200px;
}
diff --git a/src/registrar/migrations/0102_domain_dsdata_last_change.py b/src/registrar/migrations/0102_domain_dsdata_last_change.py
new file mode 100644
index 000000000..6ea631b6a
--- /dev/null
+++ b/src/registrar/migrations/0102_domain_dsdata_last_change.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.10 on 2024-06-14 19:26
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0101_domaininformation_cisa_representative_first_name_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="domain",
+ name="dsdata_last_change",
+ field=models.TextField(blank=True, help_text="Record of the last change event for ds data", null=True),
+ ),
+ ]
diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py
index 26dcb89a7..767227499 100644
--- a/src/registrar/models/domain.py
+++ b/src/registrar/models/domain.py
@@ -40,6 +40,8 @@ from .utility.time_stamped_model import TimeStampedModel
from .public_contact import PublicContact
+from .user_domain_role import UserDomainRole
+
logger = logging.getLogger(__name__)
@@ -672,11 +674,29 @@ class Domain(TimeStampedModel, DomainHelper):
remRequest = commands.UpdateDomain(name=self.name)
remExtension = commands.UpdateDomainDNSSECExtension(**remParams)
remRequest.add_extension(remExtension)
+ dsdata_change_log = ""
+
+ # Get the user's email
+ user_domain_role = UserDomainRole.objects.filter(domain=self).first()
+ user_email = user_domain_role.user.email if user_domain_role else "unknown user"
+
try:
- if "dsData" in _addDnssecdata and _addDnssecdata["dsData"] is not None:
+ added_record = "dsData" in _addDnssecdata and _addDnssecdata["dsData"] is not None
+ deleted_record = "dsData" in _remDnssecdata and _remDnssecdata["dsData"] is not None
+
+ if added_record:
registry.send(addRequest, cleaned=True)
- if "dsData" in _remDnssecdata and _remDnssecdata["dsData"] is not None:
+ dsdata_change_log = f"{user_email} added a DS data record"
+ if deleted_record:
registry.send(remRequest, cleaned=True)
+ if dsdata_change_log != "": # if they add and remove a record at same time
+ dsdata_change_log = f"{user_email} added and deleted a DS data record"
+ else:
+ dsdata_change_log = f"{user_email} deleted a DS data record"
+ if dsdata_change_log != "":
+ self.dsdata_last_change = dsdata_change_log
+ self.save() # audit log will now record this as a change
+
except RegistryError as e:
logger.error("Error updating DNSSEC, code was %s error was %s" % (e.code, e))
raise e
@@ -1057,6 +1077,12 @@ class Domain(TimeStampedModel, DomainHelper):
verbose_name="first ready on",
)
+ dsdata_last_change = TextField(
+ null=True,
+ blank=True,
+ help_text="Record of the last change event for ds data",
+ )
+
def isActive(self):
return self.state == Domain.State.CREATED
diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py
index 3e616f202..cffe5f86c 100644
--- a/src/registrar/models/domain_request.py
+++ b/src/registrar/models/domain_request.py
@@ -17,8 +17,6 @@ from .utility.time_stamped_model import TimeStampedModel
from ..utility.email import send_templated_email, EmailSendingError
from itertools import chain
-from auditlog.models import AuditlogHistoryField # type: ignore
-
logger = logging.getLogger(__name__)
@@ -35,11 +33,7 @@ class DomainRequest(TimeStampedModel):
]
# 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
- # extra_context in the change_view method for DomainRequestAdmin.
- # This is the more straightforward way so trying it first.
- history = AuditlogHistoryField()
+ # history = AuditlogHistoryField()
# Constants for choice fields
class DomainRequestStatus(models.TextChoices):
@@ -262,6 +256,11 @@ class DomainRequest(TimeStampedModel):
NAMING_REQUIREMENTS = "naming_not_met", "Naming requirements not met"
OTHER = "other", "Other/Unspecified"
+ @classmethod
+ def get_rejection_reason_label(cls, rejection_reason: str):
+ """Returns the associated label for a given rejection reason"""
+ return cls(rejection_reason).label if rejection_reason else None
+
class ActionNeededReasons(models.TextChoices):
"""Defines common action needed reasons for domain requests"""
@@ -271,6 +270,11 @@ class DomainRequest(TimeStampedModel):
BAD_NAME = ("bad_name", "Doesn’t meet naming requirements")
OTHER = ("other", "Other (no auto-email sent)")
+ @classmethod
+ def get_action_needed_reason_label(cls, action_needed_reason: str):
+ """Returns the associated label for a given action needed reason"""
+ return cls(action_needed_reason).label if action_needed_reason else None
+
# #### Internal fields about the domain request #####
status = FSMField(
choices=DomainRequestStatus.choices, # possible states as an array of constants
diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
index b6c926f8b..0f4274802 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -68,42 +68,52 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endblock field_readonly %}
{% block after_help_text %}
- {% if field.field.name == "status" and original_object.history.count > 0 %}
-
-
-
-
+
+
{% elif field.field.name == "creator" %}
@@ -174,5 +184,19 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endif %}
+ {% elif field.field.name == "investigator" and not field.is_readonly %}
+
+
+
+
{% endif %}
{% endblock after_help_text %}
diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html
index fd54769a8..15261440d 100644
--- a/src/registrar/templates/home.html
+++ b/src/registrar/templates/home.html
@@ -23,10 +23,39 @@
-
- Domains
-
-
+
+
+
+
Your registered domains
@@ -50,7 +79,7 @@
aria-live="polite"
>
-
+
You don't have any registered domains.
@@ -61,6 +90,9 @@
+
+
No results found for ""
+
-
- Domain requests
-
-
+
+
+
+
Domain requests
+
+
+
+
+
Your domain requests
@@ -82,7 +143,7 @@
Date submitted |
Status |
Action |
- Delete Action |
+
@@ -93,45 +154,13 @@
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
>
-
- {% for domain_request in domain_requests %}
- {% if has_deletable_domain_requests %}
- {% if domain_request.status == domain_request.DomainRequestStatus.STARTED or domain_request.status == domain_request.DomainRequestStatus.WITHDRAWN %}
-
-
-
- {% endif %}
- {% endif %}
- {% endfor %}
-
-
+
You haven't requested any domains.
-
+
+
+
No results found for ""
+