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/docs/operations/data_migration.md b/docs/operations/data_migration.md
index bc1aa908d..75f2f27a0 100644
--- a/docs/operations/data_migration.md
+++ b/docs/operations/data_migration.md
@@ -724,3 +724,31 @@ Example: `cf ssh getgov-za`
#### Step 1: Running the script
```docker-compose exec app ./manage.py transfer_federal_agency_type```
+
+## Email current metadata report
+
+### Running on sandboxes
+
+#### Step 1: Login to CloudFoundry
+```cf login -a api.fr.cloud.gov --sso```
+
+#### Step 2: SSH into your environment
+```cf ssh getgov-{space}```
+
+Example: `cf ssh getgov-za`
+
+#### Step 3: Create a shell instance
+```/tmp/lifecycle/shell```
+
+#### Step 4: Running the script
+```./manage.py email_current_metadata_report --emailTo {desired email address}```
+
+### Running locally
+
+#### Step 1: Running the script
+```docker-compose exec app ./manage.py email_current_metadata_report --emailTo {desired email address}```
+
+##### Parameters
+| | Parameter | Description |
+|:-:|:-------------------------- |:-----------------------------------------------------------------------------------|
+| 1 | **emailTo** | Specifies where the email will be emailed. Defaults to help@get.gov on production. |
\ No newline at end of file
diff --git a/docs/operations/import_export.md b/docs/operations/import_export.md
index 7ddfd5d3b..b7fd50d52 100644
--- a/docs/operations/import_export.md
+++ b/docs/operations/import_export.md
@@ -32,9 +32,11 @@ For reference, the zip file will contain the following tables in csv form:
* DomainInformation
* DomainUserRole
* DraftDomain
+* FederalAgency
* Websites
* Host
* HostIP
+* PublicContact
After exporting the file from the target environment, scp the exported_tables.zip
file from the target environment to local. Run the below commands from local.
@@ -75,17 +77,25 @@ For reference, this deletes all rows from the following tables:
* DomainInformation
* DomainRequest
* Domain
-* User (all but the current user)
+* User
* Contact
* Websites
* DraftDomain
* HostIP
* Host
+* PublicContact
+* FederalAgency
#### Importing into Target Environment
Once target environment is prepared, files can be imported.
+If importing tables from stable environment into an OT&E sandbox, there will be a difference
+between the stable's registry and the sandbox's registry. Therefore, you need to run import_tables
+with --skipEppSave option set to False. If you set to False, it will attempt to save PublicContact
+records to the registry on load. If this is unset, or set to True, it will load the database and not
+attempt to update the registry on load.
+
To scp the exported_tables.zip file from local to the sandbox, run the following:
Get passcode by running:
@@ -107,7 +117,7 @@ cf ssh {target-app}
example cleaning getgov-backup:
cf ssh getgov-backup
/tmp/lifecycle/backup
-./manage.py import_tables
+./manage.py import_tables --no-skipEppSave
For reference, this imports tables in the following order:
@@ -118,9 +128,11 @@ For reference, this imports tables in the following order:
* HostIP
* DraftDomain
* Websites
+* FederalAgency
* DomainRequest
* DomainInformation
* UserDomainRole
+* PublicContact
Optional step:
* Run fixtures to load fixture users back in
\ No newline at end of file
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 6f4c7e75c..d1cb287a3 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 _
@@ -1232,7 +1233,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
search_help_text = "Search by domain."
fieldsets = [
- (None, {"fields": ["creator", "submitter", "domain_request", "notes"]}),
+ (None, {"fields": ["portfolio", "creator", "submitter", "domain_request", "notes"]}),
(".gov domain", {"fields": ["domain"]}),
("Contacts", {"fields": ["authorizing_official", "other_contacts", "no_other_contacts_rationale"]}),
("Background info", {"fields": ["anything_else"]}),
@@ -1318,6 +1319,32 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
change_form_template = "django/admin/domain_information_change_form.html"
+ superuser_only_fields = [
+ "portfolio",
+ ]
+
+ # DEVELOPER's NOTE:
+ # Normally, to exclude a field from an Admin form, we could simply utilize
+ # Django's "exclude" feature. However, it causes a "missing key" error if we
+ # go that route for this particular form. The error gets thrown by our
+ # custom fieldset.html code and is due to the fact that "exclude" removes
+ # fields from base_fields but not fieldsets. Rather than reworking our
+ # custom frontend, it seems more straightforward (and easier to read) to simply
+ # modify the fieldsets list so that it excludes any fields we want to remove
+ # based on permissions (eg. superuser_only_fields) or other conditions.
+ def get_fieldsets(self, request, obj=None):
+ fieldsets = self.fieldsets
+
+ # Create a modified version of fieldsets to exclude certain fields
+ if not request.user.has_perm("registrar.full_access_permission"):
+ modified_fieldsets = []
+ for name, data in fieldsets:
+ fields = data.get("fields", [])
+ fields = tuple(field for field in fields if field not in DomainInformationAdmin.superuser_only_fields)
+ modified_fieldsets.append((name, {"fields": fields}))
+ return modified_fieldsets
+ return fieldsets
+
def get_readonly_fields(self, request, obj=None):
"""Set the read-only state on form elements.
We have 1 conditions that determine which fields are read-only:
@@ -1481,6 +1508,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
None,
{
"fields": [
+ "portfolio",
"status",
"rejection_reason",
"action_needed_reason",
@@ -1591,6 +1619,32 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
+ superuser_only_fields = [
+ "portfolio",
+ ]
+
+ # DEVELOPER's NOTE:
+ # Normally, to exclude a field from an Admin form, we could simply utilize
+ # Django's "exclude" feature. However, it causes a "missing key" error if we
+ # go that route for this particular form. The error gets thrown by our
+ # custom fieldset.html code and is due to the fact that "exclude" removes
+ # fields from base_fields but not fieldsets. Rather than reworking our
+ # custom frontend, it seems more straightforward (and easier to read) to simply
+ # modify the fieldsets list so that it excludes any fields we want to remove
+ # based on permissions (eg. superuser_only_fields) or other conditions.
+ def get_fieldsets(self, request, obj=None):
+ fieldsets = super().get_fieldsets(request, obj)
+
+ # Create a modified version of fieldsets to exclude certain fields
+ if not request.user.has_perm("registrar.full_access_permission"):
+ modified_fieldsets = []
+ for name, data in fieldsets:
+ fields = data.get("fields", [])
+ fields = tuple(field for field in fields if field not in self.superuser_only_fields)
+ modified_fieldsets.append((name, {"fields": fields}))
+ return modified_fieldsets
+ return fieldsets
+
# Table ordering
# NOTE: This impacts the select2 dropdowns (combobox)
# Currentl, there's only one for requests on DomainInfo
@@ -1818,10 +1872,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."""
@@ -1853,13 +1990,7 @@ class DomainInformationInline(admin.StackedInline):
template = "django/admin/includes/domain_info_inline_stacked.html"
model = models.DomainInformation
- fieldsets = copy.deepcopy(DomainInformationAdmin.fieldsets)
- # remove .gov domain from fieldset
- for index, (title, f) in enumerate(fieldsets):
- if title == ".gov domain":
- del fieldsets[index]
- break
-
+ fieldsets = DomainInformationAdmin.fieldsets
readonly_fields = DomainInformationAdmin.readonly_fields
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
@@ -1906,6 +2037,23 @@ class DomainInformationInline(admin.StackedInline):
def get_readonly_fields(self, request, obj=None):
return DomainInformationAdmin.get_readonly_fields(self, request, obj=None)
+ # Re-route the get_fieldsets method to utilize DomainInformationAdmin.get_fieldsets
+ # since that has all the logic for excluding certain fields according to user permissions.
+ # Then modify the remaining fields to further trim out any we don't want for this inline
+ # form
+ def get_fieldsets(self, request, obj=None):
+ # Grab fieldsets from DomainInformationAdmin so that it handles all logic
+ # for permission-based field visibility.
+ modified_fieldsets = DomainInformationAdmin.get_fieldsets(self, request, obj=None)
+
+ # remove .gov domain from fieldset
+ for index, (title, f) in enumerate(modified_fieldsets):
+ if title == ".gov domain":
+ del modified_fieldsets[index]
+ break
+
+ return modified_fieldsets
+
class DomainResource(FsmModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the
@@ -2394,16 +2542,35 @@ class PublicContactResource(resources.ModelResource):
class Meta:
model = models.PublicContact
+ # may want to consider these bulk options in future, so left in as comments
+ # use_bulk = True
+ # batch_size = 1000
+ # force_init_instance = True
- def import_row(self, row, instance_loader, using_transactions=True, dry_run=False, raise_errors=None, **kwargs):
- """Override kwargs skip_epp_save and set to True"""
- kwargs["skip_epp_save"] = True
- return super().import_row(
- row,
- instance_loader,
- using_transactions=using_transactions,
- dry_run=dry_run,
- raise_errors=raise_errors,
+ def __init__(self):
+ """Sets global variables for code tidyness"""
+ super().__init__()
+ self.skip_epp_save = False
+
+ def import_data(
+ self,
+ dataset,
+ dry_run=False,
+ raise_errors=False,
+ use_transactions=None,
+ collect_failed_rows=False,
+ rollback_on_validation_errors=False,
+ **kwargs,
+ ):
+ """Override import_data to set self.skip_epp_save if in kwargs"""
+ self.skip_epp_save = kwargs.get("skip_epp_save", False)
+ return super().import_data(
+ dataset,
+ dry_run,
+ raise_errors,
+ use_transactions,
+ collect_failed_rows,
+ rollback_on_validation_errors,
**kwargs,
)
@@ -2419,7 +2586,7 @@ class PublicContactResource(resources.ModelResource):
# we don't have transactions and we want to do a dry_run
pass
else:
- instance.save(skip_epp_save=True)
+ instance.save(skip_epp_save=self.skip_epp_save)
self.after_save_instance(instance, using_transactions, dry_run)
@@ -2468,11 +2635,48 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
super().save_model(request, obj, form, change)
-class FederalAgencyAdmin(ListHeaderAdmin):
+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"""
+
+ class Meta:
+ model = models.FederalAgency
+
+
+class FederalAgencyAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["agency"]
search_fields = ["agency"]
search_help_text = "Search by agency name."
ordering = ["agency"]
+ resource_classes = [FederalAgencyResource]
class UserGroupAdmin(AuditedAdmin):
@@ -2516,6 +2720,14 @@ class WaffleFlagAdmin(FlagAdmin):
fields = "__all__"
+class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
+ list_display = ["name", "portfolio"]
+
+
+class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
+ list_display = ["name", "portfolio"]
+
+
admin.site.unregister(LogEntry) # Unregister the default registration
admin.site.register(LogEntry, CustomLogEntryAdmin)
@@ -2538,6 +2750,9 @@ 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)
+admin.site.register(models.DomainGroup, DomainGroupAdmin)
+admin.site.register(models.Suborganization, SuborganizationAdmin)
# 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 0f3d8b2ad..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 (){
@@ -360,6 +401,30 @@ function initializeWidgetOnList(list, parentId) {
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);
+ })
+ });
})();
/** 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..e6ae0927a 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -17,6 +17,49 @@ 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');
+};
+
+/**
+ * Helper function that scrolls to an element
+ * @param {string} attributeName - The string "class" or "id"
+ * @param {string} attributeValue - The class or id name
+ */
+function ScrollToElement(attributeName, attributeValue) {
+ let targetEl = null;
+
+ if (attributeName === 'class') {
+ targetEl = document.getElementsByClassName(attributeValue)[0];
+ } else if (attributeName === 'id') {
+ targetEl = document.getElementById(attributeValue);
+ } else {
+ console.log('Error: unknown attribute name provided.');
+ return; // Exit the function if an invalid attributeName is provided
+ }
+
+ if (targetEl) {
+ const rect = targetEl.getBoundingClientRect();
+ const scrollTop = window.scrollY || document.documentElement.scrollTop;
+ window.scrollTo({
+ top: rect.top + scrollTop,
+ behavior: 'smooth' // Optional: for smooth scrolling
+ });
+ }
+}
+
/** Makes an element invisible. */
function makeHidden(el) {
el.style.position = "absolute";
@@ -879,33 +922,6 @@ function unloadModals() {
window.modal.off();
}
-/**
- * Helper function that scrolls to an element
- * @param {string} attributeName - The string "class" or "id"
- * @param {string} attributeValue - The class or id name
- */
-function ScrollToElement(attributeName, attributeValue) {
- let targetEl = null;
-
- if (attributeName === 'class') {
- targetEl = document.getElementsByClassName(attributeValue)[0];
- } else if (attributeName === 'id') {
- targetEl = document.getElementById(attributeValue);
- } else {
- console.log('Error: unknown attribute name provided.');
- return; // Exit the function if an invalid attributeName is provided
- }
-
- if (targetEl) {
- const rect = targetEl.getBoundingClientRect();
- const scrollTop = window.scrollY || document.documentElement.scrollTop;
- window.scrollTo({
- top: rect.top + scrollTop,
- behavior: 'smooth' // Optional: for smooth scrolling
- });
- }
-}
-
/**
* Generalized function to update pagination for a list.
* @param {string} itemName - The name displayed in the counter
@@ -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,75 @@ 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) {
+
+ // Use to debug uswds modal issues
+ //console.log('deleteDomainRequest')
+
+ // 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 +1333,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 +1345,146 @@ 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 = '';
+ // Unload modals will re-inject the DOM with the initial placeholders to allow for .on() in regular use cases
+ // We do NOT want that as it will cause multiple placeholders and therefore multiple inits on delete,
+ // which will cause bad delete requests to be sent.
+ const preExistingModalPlaceholders = document.querySelectorAll('[data-placeholder-for^="toggle-delete-domain-alert"]');
+ preExistingModalPlaceholders.forEach(element => {
+ element.remove();
+ });
+
// 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 %} - | -