diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml
index f2b4303d6..84f228893 100644
--- a/.github/workflows/deploy-sandbox.yaml
+++ b/.github/workflows/deploy-sandbox.yaml
@@ -27,6 +27,7 @@ jobs:
|| startsWith(github.head_ref, 'cb/')
|| startsWith(github.head_ref, 'hotgov/')
|| startsWith(github.head_ref, 'litterbox/')
+ || startsWith(github.head_ref, 'ag/')
outputs:
environment: ${{ steps.var.outputs.environment}}
runs-on: "ubuntu-latest"
diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml
index 283380236..81368f6e9 100644
--- a/.github/workflows/migrate.yaml
+++ b/.github/workflows/migrate.yaml
@@ -16,6 +16,7 @@ on:
- stable
- staging
- development
+ - ag
- litterbox
- hotgov
- cb
diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml
index b9393415b..ad325c50a 100644
--- a/.github/workflows/reset-db.yaml
+++ b/.github/workflows/reset-db.yaml
@@ -16,6 +16,7 @@ on:
options:
- staging
- development
+ - ag
- litterbox
- hotgov
- cb
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/ops/manifests/manifest-ag.yaml b/ops/manifests/manifest-ag.yaml
new file mode 100644
index 000000000..68d630f3e
--- /dev/null
+++ b/ops/manifests/manifest-ag.yaml
@@ -0,0 +1,32 @@
+---
+applications:
+- name: getgov-ag
+ buildpacks:
+ - python_buildpack
+ path: ../../src
+ instances: 1
+ memory: 512M
+ stack: cflinuxfs4
+ timeout: 180
+ command: ./run.sh
+ health-check-type: http
+ health-check-http-endpoint: /health
+ health-check-invocation-timeout: 40
+ env:
+ # Send stdout and stderr straight to the terminal without buffering
+ PYTHONUNBUFFERED: yup
+ # Tell Django where to find its configuration
+ DJANGO_SETTINGS_MODULE: registrar.config.settings
+ # Tell Django where it is being hosted
+ DJANGO_BASE_URL: https://getgov-ag.app.cloud.gov
+ # Tell Django how much stuff to log
+ DJANGO_LOG_LEVEL: INFO
+ # default public site location
+ GETGOV_PUBLIC_SITE_URL: https://get.gov
+ # Flag to disable/enable features in prod environments
+ IS_PRODUCTION: False
+ routes:
+ - route: getgov-ag.app.cloud.gov
+ services:
+ - getgov-credentials
+ - getgov-ag-database
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index 5344d7059..215239d66 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 _
@@ -217,6 +218,7 @@ class DomainRequestAdminForm(forms.ModelForm):
status = cleaned_data.get("status")
investigator = cleaned_data.get("investigator")
rejection_reason = cleaned_data.get("rejection_reason")
+ action_needed_reason = cleaned_data.get("action_needed_reason")
# Get the old status
initial_status = self.initial.get("status", None)
@@ -240,6 +242,8 @@ class DomainRequestAdminForm(forms.ModelForm):
# If the status is rejected, a rejection reason must exist
if status == DomainRequest.DomainRequestStatus.REJECTED:
self._check_for_valid_rejection_reason(rejection_reason)
+ elif status == DomainRequest.DomainRequestStatus.ACTION_NEEDED:
+ self._check_for_valid_action_needed_reason(action_needed_reason)
return cleaned_data
@@ -263,6 +267,18 @@ class DomainRequestAdminForm(forms.ModelForm):
return is_valid
+ def _check_for_valid_action_needed_reason(self, action_needed_reason) -> bool:
+ """
+ Checks if the action_needed_reason field is not none.
+ Adds form errors on failure.
+ """
+ is_valid = action_needed_reason is not None and action_needed_reason != ""
+ if not is_valid:
+ error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_ACTION_NEEDED_REASON)
+ self.add_error("action_needed_reason", error_message)
+
+ return is_valid
+
def _check_for_valid_investigator(self, investigator) -> bool:
"""
Checks if the investigator field is not none, and is staff.
@@ -1166,6 +1182,8 @@ class DomainInvitationAdmin(ListHeaderAdmin):
# error.
readonly_fields = ["status"]
+ autocomplete_fields = ["domain"]
+
change_form_template = "django/admin/email_clipboard_change_form.html"
# Select domain invitations to change -> Domain invitations
@@ -1466,6 +1484,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"fields": [
"status",
"rejection_reason",
+ "action_needed_reason",
"investigator",
"creator",
"submitter",
@@ -1482,6 +1501,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"authorizing_official",
"other_contacts",
"no_other_contacts_rationale",
+ "cisa_representative_first_name",
+ "cisa_representative_last_name",
"cisa_representative_email",
]
},
@@ -1557,6 +1578,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"no_other_contacts_rationale",
"anything_else",
"is_policy_acknowledged",
+ "cisa_representative_first_name",
+ "cisa_representative_last_name",
"cisa_representative_email",
]
autocomplete_fields = [
@@ -1668,6 +1691,8 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# The opposite of this condition is acceptable (rejected -> other status and rejection_reason)
# because we clean up the rejection reason in the transition in the model.
error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_REJECTION_REASON)
+ elif obj.status == models.DomainRequest.DomainRequestStatus.ACTION_NEEDED and not obj.action_needed_reason:
+ error_message = FSMDomainRequestError.get_error_message(FSMErrorCodes.NO_ACTION_NEEDED_REASON)
else:
# This is an fsm in model which will throw an error if the
# transition condition is violated, so we roll back the
@@ -1794,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."""
@@ -2463,6 +2571,34 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
super().save_model(request, obj, form, change)
+class PortfolioAdmin(ListHeaderAdmin):
+ # NOTE: these are just placeholders. Not part of ACs (haven't been defined yet). Update in future tickets.
+ list_display = ("organization_name", "federal_agency", "creator")
+ search_fields = ["organization_name"]
+ search_help_text = "Search by organization name."
+ # readonly_fields = [
+ # "requestor",
+ # ]
+
+ def save_model(self, request, obj, form, change):
+
+ if obj.creator is not None:
+ # ---- update creator ----
+ # Set the creator field to the current admin user
+ obj.creator = request.user if request.user.is_authenticated else None
+
+ # ---- update organization name ----
+ # org name will be the same as federal agency, if it is federal,
+ # otherwise it will be the actual org name. If nothing is entered for
+ # org name and it is a federal organization, have this field fill with
+ # the federal agency text name.
+ is_federal = obj.organization_type == DomainRequest.OrganizationChoices.FEDERAL
+ if is_federal and obj.organization_name is None:
+ obj.organization_name = obj.federal_agency.agency
+
+ super().save_model(request, obj, form, change)
+
+
class FederalAgencyResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
import/export file"""
@@ -2542,6 +2678,7 @@ admin.site.register(models.PublicContact, PublicContactAdmin)
admin.site.register(models.DomainRequest, DomainRequestAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)
+admin.site.register(models.Portfolio, PortfolioAdmin)
# Register our custom waffle implementations
admin.site.register(models.WaffleFlag, WaffleFlagAdmin)
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 702364cba..524cfe594 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 (){
@@ -300,42 +341,90 @@ function initializeWidgetOnList(list, parentId) {
*/
(function (){
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
+ let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
- if (rejectionReasonFormGroup) {
+ if (rejectionReasonFormGroup && actionNeededReasonFormGroup) {
let statusSelect = document.getElementById('id_status')
+ let isRejected = statusSelect.value == "rejected"
+ let isActionNeeded = statusSelect.value == "action needed"
// Initial handling of rejectionReasonFormGroup display
- if (statusSelect.value != 'rejected')
- rejectionReasonFormGroup.style.display = 'none';
+ showOrHideObject(rejectionReasonFormGroup, show=isRejected)
+ showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
// Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage
statusSelect.addEventListener('change', function() {
- if (statusSelect.value == 'rejected') {
- rejectionReasonFormGroup.style.display = 'block';
- sessionStorage.removeItem('hideRejectionReason');
- } else {
- rejectionReasonFormGroup.style.display = 'none';
- sessionStorage.setItem('hideRejectionReason', 'true');
- }
+ // Show the rejection reason field if the status is rejected.
+ // Then track if its shown or hidden in our session cache.
+ isRejected = statusSelect.value == "rejected"
+ showOrHideObject(rejectionReasonFormGroup, show=isRejected)
+ addOrRemoveSessionBoolean("showRejectionReason", add=isRejected)
+
+ isActionNeeded = statusSelect.value == "action needed"
+ showOrHideObject(actionNeededReasonFormGroup, show=isActionNeeded)
+ addOrRemoveSessionBoolean("showActionNeededReason", add=isActionNeeded)
});
+
+ // Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
+
+ // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
+ // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
+ // accurately for this edge case, we use cache and test for the back/forward navigation.
+ const observer = new PerformanceObserver((list) => {
+ list.getEntries().forEach((entry) => {
+ if (entry.type === "back_forward") {
+ let showRejectionReason = sessionStorage.getItem("showRejectionReason") !== null
+ showOrHideObject(rejectionReasonFormGroup, show=showRejectionReason)
+
+ let showActionNeededReason = sessionStorage.getItem("showActionNeededReason") !== null
+ showOrHideObject(actionNeededReasonFormGroup, show=showActionNeededReason)
+ }
+ });
+ });
+ observer.observe({ type: "navigation" });
}
- // Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage
+ // Adds or removes the display-none class to object depending on the value of boolean show
+ function showOrHideObject(object, show){
+ if (show){
+ object.classList.remove("display-none");
+ }else {
+ object.classList.add("display-none");
+ }
+ }
- // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the
- // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide
- // accurately for this edge case, we use cache and test for the back/forward navigation.
- const observer = new PerformanceObserver((list) => {
- list.getEntries().forEach((entry) => {
- if (entry.type === "back_forward") {
- if (sessionStorage.getItem('hideRejectionReason'))
- document.querySelector('.field-rejection_reason').style.display = 'none';
- else
- document.querySelector('.field-rejection_reason').style.display = 'block';
- }
- });
+ // Adds or removes a boolean from our session
+ function addOrRemoveSessionBoolean(name, add){
+ if (add) {
+ sessionStorage.setItem(name, "true");
+ }else {
+ sessionStorage.removeItem(name);
+ }
+ }
+
+ document.addEventListener('DOMContentLoaded', function() {
+ let statusSelect = document.getElementById('id_status');
+
+ function moveStatusChangelog(actionNeededReasonFormGroup, statusSelect) {
+ let flexContainer = actionNeededReasonFormGroup.querySelector('.flex-container');
+ let statusChangelog = document.getElementById('dja-status-changelog');
+ if (statusSelect.value === "action needed") {
+ flexContainer.parentNode.insertBefore(statusChangelog, flexContainer.nextSibling);
+ } else {
+ // Move the changelog back to its original location
+ let statusFlexContainer = statusSelect.closest('.flex-container');
+ statusFlexContainer.parentNode.insertBefore(statusChangelog, statusFlexContainer.nextSibling);
+ }
+ }
+
+ // Call the function on page load
+ moveStatusChangelog(actionNeededReasonFormGroup, statusSelect);
+
+ // Add event listener to handle changes to the selector itself
+ statusSelect.addEventListener('change', function() {
+ moveStatusChangelog(actionNeededReasonFormGroup, statusSelect);
+ })
});
- observer.observe({ type: "navigation" });
})();
/** An IIFE for toggling the submit bar on domain request forms
diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 0d594b315..86bb1fe6e 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 = `
+
+ ${modalDescription} +
+Status | +User | +Changed at | +
---|---|---|
Status | -User | -Changed at | -
{{ value.1|default:"None" }} | -{{ log_entry.actor|default:"None" }} | -{{ log_entry.timestamp|default:"None" }} | -+ {% if entry.status %} + {{ entry.status|default:"Error" }} + {% else %} + Error {% endif %} - {% endfor %} - {% endfor %} - | -
Provide details below (*).
{% with attr_maxlength=2000 add_label_class="usa-sr-only" %} {% input_with_errors forms.3.anything_else %} {% endwith %} {# forms.3 is a form for inputting the e-mail of a cisa representative #} -Date submitted | Status | Action | -Delete Action | +
---|