Merge branch 'main' into za/1901-allow-analysts-to-customize-action-needed-emails

This commit is contained in:
zandercymatics 2024-07-03 09:11:56 -06:00
commit b93a9b07b7
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
25 changed files with 621 additions and 104 deletions

View file

@ -602,6 +602,27 @@ class UserContactInline(admin.StackedInline):
model = models.Contact model = models.Contact
# Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [
"user",
"email",
]
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:
admin user permissions.
"""
readonly_fields = list(self.readonly_fields)
if request.user.has_perm("registrar.full_access_permission"):
return readonly_fields
# Return restrictive Read-only fields for analysts and
# users who might not belong to groups
readonly_fields.extend([field for field in self.analyst_readonly_fields])
return readonly_fields # Read-only fields for analysts
class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin): class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
"""Custom user admin class to use our inlines.""" """Custom user admin class to use our inlines."""
@ -649,7 +670,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
None, None,
{"fields": ("username", "password", "status", "verification_type")}, {"fields": ("username", "password", "status", "verification_type")},
), ),
("Personal info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}), ("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
( (
"Permissions", "Permissions",
{ {
@ -680,7 +701,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
) )
}, },
), ),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}), ("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
( (
"Permissions", "Permissions",
{ {
@ -704,7 +725,7 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
# NOT all fields are readonly for admin, otherwise we would have # NOT all fields are readonly for admin, otherwise we would have
# set this at the permissions level. The exception is 'status' # set this at the permissions level. The exception is 'status'
analyst_readonly_fields = [ analyst_readonly_fields = [
"Personal Info", "User profile",
"first_name", "first_name",
"middle_name", "middle_name",
"last_name", "last_name",
@ -941,6 +962,7 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Read only that we'll leverage for CISA Analysts # Read only that we'll leverage for CISA Analysts
analyst_readonly_fields = [ analyst_readonly_fields = [
"user", "user",
"email",
] ]
def get_readonly_fields(self, request, obj=None): def get_readonly_fields(self, request, obj=None):
@ -1237,7 +1259,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
search_help_text = "Search by domain." search_help_text = "Search by domain."
fieldsets = [ fieldsets = [
(None, {"fields": ["portfolio", "creator", "submitter", "domain_request", "notes"]}), (None, {"fields": ["portfolio", "sub_organization", "creator", "submitter", "domain_request", "notes"]}),
(".gov domain", {"fields": ["domain"]}), (".gov domain", {"fields": ["domain"]}),
("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}), ("Contacts", {"fields": ["senior_official", "other_contacts", "no_other_contacts_rationale"]}),
("Background info", {"fields": ["anything_else"]}), ("Background info", {"fields": ["anything_else"]}),
@ -1316,6 +1338,8 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"senior_official", "senior_official",
"domain", "domain",
"submitter", "submitter",
"portfolio",
"sub_organization",
] ]
# Table ordering # Table ordering
@ -1325,6 +1349,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
superuser_only_fields = [ superuser_only_fields = [
"portfolio", "portfolio",
"sub_organization",
] ]
# DEVELOPER's NOTE: # DEVELOPER's NOTE:
@ -1520,6 +1545,7 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
{ {
"fields": [ "fields": [
"portfolio", "portfolio",
"sub_organization",
"status_history", "status_history",
"status", "status",
"rejection_reason", "rejection_reason",
@ -1630,11 +1656,14 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
"creator", "creator",
"senior_official", "senior_official",
"investigator", "investigator",
"portfolio",
"sub_organization",
] ]
filter_horizontal = ("current_websites", "alternative_domains", "other_contacts") filter_horizontal = ("current_websites", "alternative_domains", "other_contacts")
superuser_only_fields = [ superuser_only_fields = [
"portfolio", "portfolio",
"sub_organization",
] ]
# DEVELOPER's NOTE: # DEVELOPER's NOTE:
@ -1963,10 +1992,13 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
template_subject_path = f"emails/action_needed_reasons/{action_needed_reason}_subject.txt" template_subject_path = f"emails/action_needed_reasons/{action_needed_reason}_subject.txt"
subject_template = get_template(template_subject_path) subject_template = get_template(template_subject_path)
recipient = domain_request.creator if flag_is_active(None, "profile_feature") else domain_request.submitter if flag_is_active(None, "profile_feature"): # type: ignore
recipient = domain_request.creator
else:
recipient = domain_request.submitter
# Return the content of the rendered views # Return the content of the rendered views
context = {"domain_request": domain_request, "recipient": recipient} context = {"domain_request": domain_request, "recipient": recipient}
return { return {
"subject_text": subject_template.render(context=context), "subject_text": subject_template.render(context=context),
"email_body_text": template.render(context=context) if not custom_text else custom_text, "email_body_text": template.render(context=context) if not custom_text else custom_text,
@ -2063,14 +2095,7 @@ class DomainInformationInline(admin.StackedInline):
fieldsets = DomainInformationAdmin.fieldsets fieldsets = DomainInformationAdmin.fieldsets
readonly_fields = DomainInformationAdmin.readonly_fields readonly_fields = DomainInformationAdmin.readonly_fields
analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields
autocomplete_fields = DomainInformationAdmin.autocomplete_fields
autocomplete_fields = [
"creator",
"domain_request",
"senior_official",
"domain",
"submitter",
]
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
"""Custom has_change_permission override so that we can specify that """Custom has_change_permission override so that we can specify that
@ -2182,8 +2207,7 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
), ),
) )
# this ordering effects the ordering of results # this ordering effects the ordering of results in autocomplete_fields for domain
# in autocomplete_fields for domain
ordering = ["name"] ordering = ["name"]
def generic_org_type(self, obj): def generic_org_type(self, obj):
@ -2667,6 +2691,11 @@ class PortfolioAdmin(ListHeaderAdmin):
# readonly_fields = [ # readonly_fields = [
# "requestor", # "requestor",
# ] # ]
# Creates select2 fields (with search bars)
autocomplete_fields = [
"creator",
"federal_agency",
]
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
@ -2750,6 +2779,10 @@ class DomainGroupAdmin(ListHeaderAdmin, ImportExportModelAdmin):
class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin): class SuborganizationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
list_display = ["name", "portfolio"] list_display = ["name", "portfolio"]
autocomplete_fields = [
"portfolio",
]
search_fields = ["name"]
admin.site.unregister(LogEntry) # Unregister the default registration admin.site.unregister(LogEntry) # Unregister the default registration

View file

@ -361,7 +361,9 @@ function initializeWidgetOnList(list, parentId) {
*/ */
(function (){ (function (){
let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason') let rejectionReasonFormGroup = document.querySelector('.field-rejection_reason')
// This is the "action needed reason" field
let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason'); let actionNeededReasonFormGroup = document.querySelector('.field-action_needed_reason');
// This is the "auto-generated email" field
let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email') let actionNeededReasonEmailFormGroup = document.querySelector('.field-action_needed_reason_email')
if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) { if (rejectionReasonFormGroup && actionNeededReasonFormGroup && actionNeededReasonEmailFormGroup) {

View file

@ -46,7 +46,7 @@ function ScrollToElement(attributeName, attributeValue) {
} else if (attributeName === 'id') { } else if (attributeName === 'id') {
targetEl = document.getElementById(attributeValue); targetEl = document.getElementById(attributeValue);
} else { } else {
console.log('Error: unknown attribute name provided.'); console.error('Error: unknown attribute name provided.');
return; // Exit the function if an invalid attributeName is provided return; // Exit the function if an invalid attributeName is provided
} }
@ -78,6 +78,50 @@ function makeVisible(el) {
el.style.visibility = "visible"; el.style.visibility = "visible";
} }
/**
* Toggles expand_more / expand_more svgs in buttons or anchors
* @param {Element} element - DOM element
*/
function toggleCaret(element) {
// Get a reference to the use element inside the button
const useElement = element.querySelector('use');
// Check if the span element text is 'Hide'
if (useElement.getAttribute('xlink:href') === '/public/img/sprite.svg#expand_more') {
// Update the xlink:href attribute to expand_more
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
} else {
// Update the xlink:href attribute to expand_less
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
}
}
/**
* 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.error('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
});
}
}
/** Creates and returns a live region element. */ /** Creates and returns a live region element. */
function createLiveRegion(id) { function createLiveRegion(id) {
const liveRegion = document.createElement("div"); const liveRegion = document.createElement("div");
@ -927,7 +971,7 @@ function unloadModals() {
* @param {string} itemName - The name displayed in the counter * @param {string} itemName - The name displayed in the counter
* @param {string} paginationSelector - CSS selector for the pagination container. * @param {string} paginationSelector - CSS selector for the pagination container.
* @param {string} counterSelector - CSS selector for the pagination counter. * @param {string} counterSelector - CSS selector for the pagination counter.
* @param {string} headerAnchor - CSS selector for the header element to anchor the links to. * @param {string} linkAnchor - CSS selector for the header element to anchor the links to.
* @param {Function} loadPageFunction - Function to call when a page link is clicked. * @param {Function} loadPageFunction - Function to call when a page link is clicked.
* @param {number} currentPage - The current page number (starting with 1). * @param {number} currentPage - The current page number (starting with 1).
* @param {number} numPages - The total number of pages. * @param {number} numPages - The total number of pages.
@ -936,7 +980,7 @@ function unloadModals() {
* @param {number} totalItems - The total number of items. * @param {number} totalItems - The total number of items.
* @param {string} searchTerm - The search term * @param {string} searchTerm - The search term
*/ */
function updatePagination(itemName, paginationSelector, counterSelector, headerAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) { function updatePagination(itemName, paginationSelector, counterSelector, linkAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) {
const paginationContainer = document.querySelector(paginationSelector); const paginationContainer = document.querySelector(paginationSelector);
const paginationCounter = document.querySelector(counterSelector); const paginationCounter = document.querySelector(counterSelector);
const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`); const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`);
@ -955,7 +999,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const prevPageItem = document.createElement('li'); const prevPageItem = document.createElement('li');
prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; prevPageItem.className = 'usa-pagination__item usa-pagination__arrow';
prevPageItem.innerHTML = ` prevPageItem.innerHTML = `
<a href="${headerAnchor}" class="usa-pagination__link usa-pagination__previous-page" aria-label="Previous page"> <a href="${linkAnchor}" class="usa-pagination__link usa-pagination__previous-page" aria-label="Previous page">
<svg class="usa-icon" aria-hidden="true" role="img"> <svg class="usa-icon" aria-hidden="true" role="img">
<use xlink:href="/public/img/sprite.svg#navigate_before"></use> <use xlink:href="/public/img/sprite.svg#navigate_before"></use>
</svg> </svg>
@ -974,7 +1018,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const pageItem = document.createElement('li'); const pageItem = document.createElement('li');
pageItem.className = 'usa-pagination__item usa-pagination__page-no'; pageItem.className = 'usa-pagination__item usa-pagination__page-no';
pageItem.innerHTML = ` pageItem.innerHTML = `
<a href="${headerAnchor}" class="usa-pagination__button" aria-label="Page ${page}">${page}</a> <a href="${linkAnchor}" class="usa-pagination__button" aria-label="Page ${page}">${page}</a>
`; `;
if (page === currentPage) { if (page === currentPage) {
pageItem.querySelector('a').classList.add('usa-current'); pageItem.querySelector('a').classList.add('usa-current');
@ -1020,7 +1064,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
const nextPageItem = document.createElement('li'); const nextPageItem = document.createElement('li');
nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; nextPageItem.className = 'usa-pagination__item usa-pagination__arrow';
nextPageItem.innerHTML = ` nextPageItem.innerHTML = `
<a href="${headerAnchor}" class="usa-pagination__link usa-pagination__next-page" aria-label="Next page"> <a href="${linkAnchor}" class="usa-pagination__link usa-pagination__next-page" aria-label="Next page">
<span class="usa-pagination__link-text">Next</span> <span class="usa-pagination__link-text">Next</span>
<svg class="usa-icon" aria-hidden="true" role="img"> <svg class="usa-icon" aria-hidden="true" role="img">
<use xlink:href="/public/img/sprite.svg#navigate_next"></use> <use xlink:href="/public/img/sprite.svg#navigate_next"></use>
@ -1039,20 +1083,14 @@ function updatePagination(itemName, paginationSelector, counterSelector, headerA
* A helper that toggles content/ no content/ no search results * A helper that toggles content/ no content/ no search results
* *
*/ */
const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper, searchTermHolder, currentSearchTerm) => { const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
const { unfiltered_total, total } = data; const { unfiltered_total, total } = data;
if (searchTermHolder)
searchTermHolder.innerHTML = '';
if (unfiltered_total) { if (unfiltered_total) {
if (total) { if (total) {
showElement(dataWrapper); showElement(dataWrapper);
hideElement(noSearchResultsWrapper); hideElement(noSearchResultsWrapper);
hideElement(noDataWrapper); hideElement(noDataWrapper);
} else { } else {
if (searchTermHolder)
searchTermHolder.innerHTML = currentSearchTerm;
hideElement(dataWrapper); hideElement(dataWrapper);
showElement(noSearchResultsWrapper); showElement(noSearchResultsWrapper);
hideElement(noDataWrapper); hideElement(noDataWrapper);
@ -1090,14 +1128,18 @@ document.addEventListener('DOMContentLoaded', function() {
let currentOrder = 'asc'; let currentOrder = 'asc';
const noDomainsWrapper = document.querySelector('.domains__no-data'); const noDomainsWrapper = document.querySelector('.domains__no-data');
const noSearchResultsWrapper = document.querySelector('.domains__no-search-results'); const noSearchResultsWrapper = document.querySelector('.domains__no-search-results');
let hasLoaded = false; let scrollToTable = false;
let currentSearchTerm = '' let currentStatus = [];
let currentSearchTerm = '';
const domainsSearchInput = document.getElementById('domains__search-field'); const domainsSearchInput = document.getElementById('domains__search-field');
const domainsSearchSubmit = document.getElementById('domains__search-field-submit'); const domainsSearchSubmit = document.getElementById('domains__search-field-submit');
const tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]'); const tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region'); const tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region');
const searchTermHolder = document.querySelector('.domains__search-term'); const resetSearchButton = document.querySelector('.domains__reset-search');
const resetButton = document.querySelector('.domains__reset-button'); const resetFiltersButton = document.querySelector('.domains__reset-filters');
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
const statusIndicator = document.querySelector('.domain__filter-indicator');
const statusToggle = document.querySelector('.usa-button--filter');
/** /**
* Loads rows in the domains list, as well as updates pagination around the domains list * Loads rows in the domains list, as well as updates pagination around the domains list
@ -1105,21 +1147,21 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} page - the page number of the results (starts with 1) * @param {*} page - the page number of the results (starts with 1)
* @param {*} sortBy - the sort column option * @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc} * @param {*} order - the sort order {asc, desc}
* @param {*} loaded - control for the scrollToElement functionality * @param {*} scroll - control for the scrollToElement functionality
* @param {*} searchTerm - the search term * @param {*} searchTerm - the search term
*/ */
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) { function loadDomains(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, status = currentStatus, searchTerm = currentSearchTerm) {
//fetch json of page of domains, given page # and sort // fetch json of page of domains, given params
fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`) fetch(`/get-domains-json/?page=${page}&sort_by=${sortBy}&order=${order}&status=${status}&search_term=${searchTerm}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
console.log('Error in AJAX call: ' + data.error); console.error('Error in AJAX call: ' + data.error);
return; return;
} }
// handle the display of proper messaging in the event that no domains exist in the list or search returns no results // 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); updateDisplay(data, domainsWrapper, noDomainsWrapper, noSearchResultsWrapper, currentSearchTerm);
// identify the DOM element where the domain list will be inserted into the DOM // identify the DOM element where the domain list will be inserted into the DOM
const domainList = document.querySelector('.domains__table tbody'); const domainList = document.querySelector('.domains__table tbody');
@ -1132,7 +1174,6 @@ document.addEventListener('DOMContentLoaded', function() {
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url; const actionUrl = domain.action_url;
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name"> <th scope="row" role="rowheader" data-label="Domain name">
@ -1148,7 +1189,7 @@ document.addEventListener('DOMContentLoaded', function() {
data-position="top" data-position="top"
title="${domain.get_state_help_text}" title="${domain.get_state_help_text}"
focusable="true" focusable="true"
aria-label="Status Information" aria-label="${domain.get_state_help_text}"
role="tooltip" role="tooltip"
> >
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use> <use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
@ -1169,16 +1210,16 @@ document.addEventListener('DOMContentLoaded', function() {
initializeTooltips(); initializeTooltips();
// Do not scroll on first page load // Do not scroll on first page load
if (loaded) if (scroll)
ScrollToElement('id', 'domains-header'); ScrollToElement('class', 'domains');
hasLoaded = true; scrollToTable = true;
// update pagination // update pagination
updatePagination( updatePagination(
'domain', 'domain',
'#domains-pagination', '#domains-pagination',
'#domains-pagination .usa-pagination__counter', '#domains-pagination .usa-pagination__counter',
'#domains-header', '#domains',
loadDomains, loadDomains,
data.page, data.page,
data.num_pages, data.num_pages,
@ -1214,13 +1255,51 @@ document.addEventListener('DOMContentLoaded', function() {
currentSearchTerm = domainsSearchInput.value; currentSearchTerm = domainsSearchInput.value;
// If the search is blank, we match the resetSearch functionality // If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) { if (currentSearchTerm) {
showElement(resetButton); showElement(resetSearchButton);
} else { } else {
hideElement(resetButton); hideElement(resetSearchButton);
} }
loadDomains(1, 'id', 'asc'); loadDomains(1, 'id', 'asc');
resetHeaders(); resetHeaders();
}) });
if (statusToggle) {
statusToggle.addEventListener('click', function() {
toggleCaret(statusToggle);
});
}
// Add event listeners to status filter checkboxes
statusCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
const checkboxValue = this.value;
// Update currentStatus array based on checkbox state
if (this.checked) {
currentStatus.push(checkboxValue);
} else {
const index = currentStatus.indexOf(checkboxValue);
if (index > -1) {
currentStatus.splice(index, 1);
}
}
// Manage visibility of reset filters button
if (currentStatus.length == 0) {
hideElement(resetFiltersButton);
} else {
showElement(resetFiltersButton);
}
// Disable the auto scroll
scrollToTable = false;
// Call loadDomains with updated status
loadDomains(1, 'id', 'asc');
resetHeaders();
updateStatusIndicator();
});
});
// Reset UI and accessibility // Reset UI and accessibility
function resetHeaders() { function resetHeaders() {
@ -1235,18 +1314,78 @@ document.addEventListener('DOMContentLoaded', function() {
function resetSearch() { function resetSearch() {
domainsSearchInput.value = ''; domainsSearchInput.value = '';
currentSearchTerm = ''; currentSearchTerm = '';
hideElement(resetButton); hideElement(resetSearchButton);
loadDomains(1, 'id', 'asc', hasLoaded, ''); loadDomains(1, 'id', 'asc');
resetHeaders(); resetHeaders();
} }
if (resetButton) { if (resetSearchButton) {
resetButton.addEventListener('click', function() { resetSearchButton.addEventListener('click', function() {
resetSearch(); resetSearch();
}); });
} }
// Load the first page initially function resetFilters() {
currentStatus = [];
statusCheckboxes.forEach(checkbox => {
checkbox.checked = false;
});
hideElement(resetFiltersButton);
// Disable the auto scroll
scrollToTable = false;
loadDomains(1, 'id', 'asc');
resetHeaders();
updateStatusIndicator();
// No need to toggle close the filters. The focus shift will trigger that for us.
}
if (resetFiltersButton) {
resetFiltersButton.addEventListener('click', function() {
resetFilters();
});
}
function updateStatusIndicator() {
statusIndicator.innerHTML = '';
// Even if the element is empty, it'll mess up the flex layout unless we set display none
statusIndicator.hideElement();
if (currentStatus.length)
statusIndicator.innerHTML = '(' + currentStatus.length + ')';
statusIndicator.showElement();
}
function closeFilters() {
if (statusToggle.getAttribute("aria-expanded") === "true") {
statusToggle.click();
}
}
// Instead of managing the toggle/close on the filter buttons in all edge cases (user clicks on search, user clicks on ANOTHER filter,
// user clicks on main nav...) we add a listener and close the filters whenever the focus shifts out of the dropdown menu/filter button.
// NOTE: We may need to evolve this as we add more filters.
document.addEventListener('focusin', function(event) {
const accordion = document.querySelector('.usa-accordion--select');
const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionIsOpen && !accordion.contains(event.target)) {
closeFilters();
}
});
// Close when user clicks outside
// NOTE: We may need to evolve this as we add more filters.
document.addEventListener('click', function(event) {
const accordion = document.querySelector('.usa-accordion--select');
const accordionIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionIsOpen && !accordion.contains(event.target)) {
closeFilters();
}
});
// Initial load
loadDomains(1); loadDomains(1);
} }
}); });
@ -1279,14 +1418,13 @@ document.addEventListener('DOMContentLoaded', function() {
let currentOrder = 'asc'; let currentOrder = 'asc';
const noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data'); const noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data');
const noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results'); const noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results');
let hasLoaded = false; let scrollToTable = false;
let currentSearchTerm = '' let currentSearchTerm = '';
const domainRequestsSearchInput = document.getElementById('domain-requests__search-field'); const domainRequestsSearchInput = document.getElementById('domain-requests__search-field');
const domainRequestsSearchSubmit = document.getElementById('domain-requests__search-field-submit'); const domainRequestsSearchSubmit = document.getElementById('domain-requests__search-field-submit');
const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]'); const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region'); const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
const searchTermHolder = document.querySelector('.domain-requests__search-term'); const resetSearchButton = document.querySelector('.domain-requests__reset-search');
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. * 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.
@ -1316,7 +1454,7 @@ document.addEventListener('DOMContentLoaded', function() {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
// Update data and UI // Update data and UI
loadDomainRequests(pageToDisplay, currentSortBy, currentOrder, hasLoaded, currentSearchTerm); loadDomainRequests(pageToDisplay, currentSortBy, currentOrder, scrollToTable, currentSearchTerm);
}) })
.catch(error => console.error('Error fetching domain requests:', error)); .catch(error => console.error('Error fetching domain requests:', error));
} }
@ -1332,21 +1470,21 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} page - the page number of the results (starts with 1) * @param {*} page - the page number of the results (starts with 1)
* @param {*} sortBy - the sort column option * @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc} * @param {*} order - the sort order {asc, desc}
* @param {*} loaded - control for the scrollToElement functionality * @param {*} scroll - control for the scrollToElement functionality
* @param {*} searchTerm - the search term * @param {*} searchTerm - the search term
*/ */
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, loaded = hasLoaded, searchTerm = currentSearchTerm) { function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm) {
//fetch json of page of domain requests, given page # and sort // fetch json of page of domain requests, given params
fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`) fetch(`/get-domain-requests-json/?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
console.log('Error in AJAX call: ' + data.error); console.error('Error in AJAX call: ' + data.error);
return; return;
} }
// handle the display of proper messaging in the event that no requests exist in the list or search returns no results // 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); updateDisplay(data, domainRequestsWrapper, noDomainRequestsWrapper, noSearchResultsWrapper, currentSearchTerm);
// identify the DOM element where the domain request list will be inserted into the DOM // identify the DOM element where the domain request list will be inserted into the DOM
const tbody = document.querySelector('.domain-requests__table tbody'); const tbody = document.querySelector('.domain-requests__table tbody');
@ -1533,16 +1671,16 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
// Do not scroll on first page load // Do not scroll on first page load
if (loaded) if (scroll)
ScrollToElement('id', 'domain-requests-header'); ScrollToElement('class', 'domain-requests');
hasLoaded = true; scrollToTable = true;
// update the pagination after the domain requests list is updated // update the pagination after the domain requests list is updated
updatePagination( updatePagination(
'domain request', 'domain request',
'#domain-requests-pagination', '#domain-requests-pagination',
'#domain-requests-pagination .usa-pagination__counter', '#domain-requests-pagination .usa-pagination__counter',
'#domain-requests-header', '#domain-requests',
loadDomainRequests, loadDomainRequests,
data.page, data.page,
data.num_pages, data.num_pages,
@ -1577,13 +1715,13 @@ document.addEventListener('DOMContentLoaded', function() {
currentSearchTerm = domainRequestsSearchInput.value; currentSearchTerm = domainRequestsSearchInput.value;
// If the search is blank, we match the resetSearch functionality // If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) { if (currentSearchTerm) {
showElement(resetButton); showElement(resetSearchButton);
} else { } else {
hideElement(resetButton); hideElement(resetSearchButton);
} }
loadDomainRequests(1, 'id', 'asc'); loadDomainRequests(1, 'id', 'asc');
resetHeaders(); resetHeaders();
}) });
// Reset UI and accessibility // Reset UI and accessibility
function resetHeaders() { function resetHeaders() {
@ -1598,24 +1736,23 @@ document.addEventListener('DOMContentLoaded', function() {
function resetSearch() { function resetSearch() {
domainRequestsSearchInput.value = ''; domainRequestsSearchInput.value = '';
currentSearchTerm = ''; currentSearchTerm = '';
hideElement(resetButton); hideElement(resetSearchButton);
loadDomainRequests(1, 'id', 'asc', hasLoaded, ''); loadDomainRequests(1, 'id', 'asc');
resetHeaders(); resetHeaders();
} }
if (resetButton) { if (resetSearchButton) {
resetButton.addEventListener('click', function() { resetSearchButton.addEventListener('click', function() {
resetSearch(); resetSearch();
}); });
} }
// Load the first page initially // Initial load
loadDomainRequests(1); loadDomainRequests(1);
} }
}); });
/** /**
* An IIFE that displays confirmation modal on the user profile page * An IIFE that displays confirmation modal on the user profile page
*/ */

View file

@ -0,0 +1,33 @@
@use "uswds-core" as *;
.usa-accordion--select {
display: inline-block;
width: auto;
position: relative;
.usa-accordion__button[aria-expanded=false],
.usa-accordion__button[aria-expanded=false]:hover,
.usa-accordion__button[aria-expanded=true],
.usa-accordion__button[aria-expanded=true]:hover {
background-image: none;
}
.usa-accordion__content {
// Note, width is determined by a custom width class on one of the children
position: absolute;
z-index: 1;
top: 33.88px;
left: 0;
border-radius: 4px;
border: solid 1px color('base-lighter');
padding: units(2) units(2) units(3) units(2);
width: max-content;
}
h2 {
font-size: size('body', 'sm');
}
.usa-button {
width: 100%;
}
.margin-top-0 {
margin-top: 0 !important;
}
}

View file

@ -841,3 +841,12 @@ div.dja__model-description{
.padding-top-0 { .padding-top-0 {
padding-top: 0 !important; padding-top: 0 !important;
} }
.flex-container {
@media screen and (min-width: 700px) and (max-width: 1150px) {
&.flex-container--mobile-inline {
display: inline !important;
}
}
}

View file

@ -83,6 +83,10 @@ body {
padding: 0 units(2) units(3); padding: 0 units(2) units(3);
margin-top: units(3); margin-top: units(3);
&.margin-top-0 {
margin-top: 0;
}
h2 { h2 {
color: color('primary-dark'); color: color('primary-dark');
margin-top: units(2); margin-top: units(2);
@ -96,6 +100,10 @@ body {
@include at-media(mobile-lg) { @include at-media(mobile-lg) {
margin-top: units(5); margin-top: units(5);
&.margin-top-0 {
margin-top: 0;
}
h2 { h2 {
margin-bottom: 0; margin-bottom: 0;
} }
@ -211,3 +219,7 @@ abbr[title] {
.usa-logo button.usa-button--unstyled.disabled-button:hover{ .usa-logo button.usa-button--unstyled.disabled-button:hover{
color: #{$dhs-dark-gray-85}; color: #{$dhs-dark-gray-85};
} }
.padding--8-8-9 {
padding: 8px 8px 9px !important;
}

View file

@ -161,3 +161,19 @@ a.usa-button--unstyled:visited {
margin-left: units(2); margin-left: units(2);
} }
} }
.usa-button--filter {
width: auto;
// For mobile stacking
margin-bottom: units(1);
border: solid 1px color('base-light') !important;
padding: units(1);
color: color('primary-darker') !important;
font-weight: font-weight('normal');
font-size: size('ui', 'xs');
box-shadow: none;
&:hover {
box-shadow: none;
}
}

View file

@ -27,7 +27,6 @@
} }
td .no-click-outline-and-cursor-help { td .no-click-outline-and-cursor-help {
outline: none;
cursor: help; cursor: help;
use { use {
// USWDS has weird interactions with SVGs regarding tooltips, // USWDS has weird interactions with SVGs regarding tooltips,

View file

@ -12,6 +12,7 @@
@forward "typography"; @forward "typography";
@forward "links"; @forward "links";
@forward "lists"; @forward "lists";
@forward "accordions";
@forward "buttons"; @forward "buttons";
@forward "pagination"; @forward "pagination";
@forward "forms"; @forward "forms";

View file

@ -0,0 +1,85 @@
# Generated by Django 4.2.10 on 2024-07-02 16:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("registrar", "0108_domaininformation_authorizing_official_and_more"),
]
operations = [
migrations.AddField(
model_name="domaininformation",
name="sub_organization",
field=models.ForeignKey(
blank=True,
help_text="The suborganization that this domain is included under",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="information_sub_organization",
to="registrar.suborganization",
),
),
migrations.AddField(
model_name="domainrequest",
name="sub_organization",
field=models.ForeignKey(
blank=True,
help_text="The suborganization that this domain request is included under",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="request_sub_organization",
to="registrar.suborganization",
),
),
migrations.AlterField(
model_name="domaininformation",
name="portfolio",
field=models.ForeignKey(
blank=True,
help_text="Portfolio associated with this domain",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="information_portfolio",
to="registrar.portfolio",
),
),
migrations.AlterField(
model_name="domainrequest",
name="approved_domain",
field=models.OneToOneField(
blank=True,
help_text="Domain associated with this request; will be blank until request is approved",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="domain_request_approved_domain",
to="registrar.domain",
),
),
migrations.AlterField(
model_name="domainrequest",
name="portfolio",
field=models.ForeignKey(
blank=True,
help_text="Portfolio associated with this domain request",
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="DomainRequest_portfolio",
to="registrar.portfolio",
),
),
migrations.AlterField(
model_name="domainrequest",
name="requested_domain",
field=models.OneToOneField(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="domain_request_requested_domain",
to="registrar.draftdomain",
),
),
]

View file

@ -63,10 +63,19 @@ class DomainInformation(TimeStampedModel):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True, blank=True,
related_name="DomainRequest_portfolio", related_name="information_portfolio",
help_text="Portfolio associated with this domain", help_text="Portfolio associated with this domain",
) )
sub_organization = models.ForeignKey(
"registrar.Suborganization",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="information_sub_organization",
help_text="The suborganization that this domain is included under",
)
domain_request = models.OneToOneField( domain_request = models.OneToOneField(
"registrar.DomainRequest", "registrar.DomainRequest",
on_delete=models.PROTECT, on_delete=models.PROTECT,
@ -361,6 +370,10 @@ class DomainInformation(TimeStampedModel):
# domain_request, if so short circuit the create # domain_request, if so short circuit the create
existing_domain_info = cls.objects.filter(domain_request__id=domain_request.id).first() existing_domain_info = cls.objects.filter(domain_request__id=domain_request.id).first()
if existing_domain_info: if existing_domain_info:
logger.info(
f"create_from_da() -> Shortcircuting create on {existing_domain_info}. "
"This record already exists. No values updated!"
)
return existing_domain_info return existing_domain_info
# Get the fields that exist on both DomainRequest and DomainInformation # Get the fields that exist on both DomainRequest and DomainInformation

View file

@ -315,10 +315,19 @@ class DomainRequest(TimeStampedModel):
on_delete=models.PROTECT, on_delete=models.PROTECT,
null=True, null=True,
blank=True, blank=True,
related_name="DomainInformation_portfolio", related_name="DomainRequest_portfolio",
help_text="Portfolio associated with this domain request", help_text="Portfolio associated with this domain request",
) )
sub_organization = models.ForeignKey(
"registrar.Suborganization",
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="request_sub_organization",
help_text="The suborganization that this domain request is included under",
)
# This is the domain request user who created this domain request. The contact # This is the domain request user who created this domain request. The contact
# information that they gave is in the `submitter` field # information that they gave is in the `submitter` field
creator = models.ForeignKey( creator = models.ForeignKey(
@ -444,7 +453,7 @@ class DomainRequest(TimeStampedModel):
null=True, null=True,
blank=True, blank=True,
help_text="Domain associated with this request; will be blank until request is approved", help_text="Domain associated with this request; will be blank until request is approved",
related_name="domain_request", related_name="domain_request_approved_domain",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
) )
@ -452,7 +461,7 @@ class DomainRequest(TimeStampedModel):
"DraftDomain", "DraftDomain",
null=True, null=True,
blank=True, blank=True,
related_name="domain_request", related_name="domain_request_requested_domain",
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )

View file

@ -32,7 +32,9 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% for field in line %} {% for field in line %}
<div> <div>
{% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %} {% if not line.fields|length == 1 and not field.is_readonly %}{{ field.errors }}{% endif %}
{% block flex_container_start %}
<div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}"> <div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}">
{% endblock flex_container_start %}
{% if field.is_checkbox %} {% if field.is_checkbox %}
{# .gov override #} {# .gov override #}
{% block field_checkbox %} {% block field_checkbox %}
@ -52,7 +54,9 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% endblock field_other%} {% endblock field_other%}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% block flex_container_end %}
</div> </div>
{% endblock flex_container_end %}
{% if field.field.help_text %} {% if field.field.help_text %}
{# .gov override #} {# .gov override #}

View file

@ -6,6 +6,15 @@
This is using a custom implementation fieldset.html (see admin/fieldset.html) This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endcomment %} {% endcomment %}
{% block flex_container_start %}
{% if field.field.name == "status_history" %}
<div class="flex-container flex-container--mobile-inline {% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}">
{% else %}
{% comment %} Default flex container element {% endcomment %}
<div class="flex-container{% if not line.fields|length == 1 %} fieldBox{% if field.field.name %} field-{{ field.field.name }}{% endif %}{% if not field.is_readonly and field.errors %} errors{% endif %}{% if field.field.is_hidden %} hidden{% endif %}{% elif field.is_checkbox %} checkbox-row{% endif %}">
{% endif %}
{% endblock flex_container_start %}
{% block field_readonly %} {% block field_readonly %}
{% with all_contacts=original_object.other_contacts.all %} {% with all_contacts=original_object.other_contacts.all %}
{% if field.field.name == "status_history" %} {% if field.field.name == "status_history" %}

View file

@ -1,6 +1,6 @@
{% load static %} {% load static %}
<section class="section--outlined domain-requests"> <section class="section--outlined domain-requests" id="domain-requests">
<div class="grid-row"> <div class="grid-row">
{% if portfolio is None %} {% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
@ -11,10 +11,10 @@
<section aria-label="Domain requests search component" class="flex-6 margin-y-2"> <section aria-label="Domain requests search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-button display-none" type="button"> <button class="usa-button usa-button--unstyled margin-right-2 domain-requests__reset-search display-none" type="button">
Reset Reset
</button> </button>
<label class="usa-sr-only" for="domain-requests__search-field">Search</label> <label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
<input <input
class="usa-input" class="usa-input"
id="domain-requests__search-field" id="domain-requests__search-field"
@ -33,7 +33,7 @@
</section> </section>
</div> </div>
</div> </div>
<div class="domain-requests__table-wrapper display-none"> <div class="domain-requests__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domain-requests__table">
<caption class="sr-only">Your domain requests</caption> <caption class="sr-only">Your domain requests</caption>
<thead> <thead>
@ -58,7 +58,7 @@
<p>You haven't requested any domains.</p> <p>You haven't requested any domains.</p>
</div> </div>
<div class="domain-requests__no-search-results display-none"> <div class="domain-requests__no-search-results display-none">
<p>No results found for "<span class="domain-requests__search-term"></span>"</p> <p>No results found</p>
</div> </div>
</section> </section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination"> <nav aria-label="Pagination" class="usa-pagination flex-justify" id="domain-requests-pagination">

View file

@ -1,6 +1,6 @@
{% load static %} {% load static %}
<section class="section--outlined domains"> <section class="section--outlined domains{% if portfolio is not None %} margin-top-0{% endif %}" id="domains">
<div class="grid-row"> <div class="grid-row">
{% if portfolio is None %} {% if portfolio is None %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
@ -11,10 +11,10 @@
<section aria-label="Domains search component" class="flex-6 margin-y-2"> <section aria-label="Domains search component" class="flex-6 margin-y-2">
<form class="usa-search usa-search--small" method="POST" role="search"> <form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %} {% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-2 domains__reset-button display-none" type="button"> <button class="usa-button usa-button--unstyled margin-right-2 domains__reset-search display-none" type="button">
Reset Reset
</button> </button>
<label class="usa-sr-only" for="domains__search-field">Search</label> <label class="usa-sr-only" for="domains__search-field">Search by domain name</label>
<input <input
class="usa-input" class="usa-input"
id="domains__search-field" id="domains__search-field"
@ -33,7 +33,102 @@
</section> </section>
</div> </div>
</div> </div>
<div class="domains__table-wrapper display-none"> {% if portfolio %}
<div class="display-flex flex-align-center margin-top-1">
<span class="margin-right-2 margin-top-neg-1 text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2">
<div class="usa-accordion__heading">
<button
type="button"
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button"
aria-expanded="false"
aria-controls="filter-status"
>
<span class="domain__filter-indicator text-bold display-none"></span> Status
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
<div id="filter-status" class="usa-accordion__content usa-prose shadow-1">
<h2>Status</h2>
<fieldset class="usa-fieldset margin-top-0">
<legend class="usa-legend">Select to apply <span class="sr-only">status</span> filter</legend>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-dns-needed"
type="checkbox"
name="filter-status"
value="unknown"
/>
<label class="usa-checkbox__label" for="filter-status-dns-needed"
>DNS Needed</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-ready"
type="checkbox"
name="filter-status"
value="ready"
/>
<label class="usa-checkbox__label" for="filter-status-ready"
>Ready</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-on-hold"
type="checkbox"
name="filter-status"
value="on hold"
/>
<label class="usa-checkbox__label" for="filter-status-on-hold"
>On hold</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-expired"
type="checkbox"
name="filter-status"
value="expired"
/>
<label class="usa-checkbox__label" for="filter-status-expired"
>Expired</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-deleted"
type="checkbox"
name="filter-status"
value="deleted"
/>
<label class="usa-checkbox__label" for="filter-status-deleted"
>Deleted</label
>
</div>
</fieldset>
</div>
</div>
<button
type="button"
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter domains__reset-filters display-none"
>
Clear filters
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#close"></use>
</svg>
</button>
</div>
{% endif %}
<div class="domains__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domains__table"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked domains__table">
<caption class="sr-only">Your registered domains</caption> <caption class="sr-only">Your registered domains</caption>
<thead> <thead>
@ -70,7 +165,7 @@
</p> </p>
</div> </div>
<div class="domains__no-search-results display-none"> <div class="domains__no-search-results display-none">
<p>No results found for "<span class="domains__search-term"></span>"</p> <p>No results found</p>
</div> </div>
</section> </section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination"> <nav aria-label="Pagination" class="usa-pagination flex-justify" id="domains-pagination">

View file

@ -3,6 +3,6 @@
{% load static %} {% load static %}
{% block portfolio_content %} {% block portfolio_content %}
<h1>Domains</h1> <h1 id="domains-header">Domains</h1>
{% include "includes/domains_table.html" with portfolio=portfolio %} {% include "includes/domains_table.html" with portfolio=portfolio %}
{% endblock %} {% endblock %}

View file

@ -3,7 +3,7 @@
{% load static %} {% load static %}
{% block portfolio_content %} {% block portfolio_content %}
<h1>Domain requests</h1> <h1 id="domain-requests-header">Domain requests</h1>
{% comment %} {% comment %}
IMPORTANT: IMPORTANT:

View file

@ -3,7 +3,7 @@
<div class="margin-bottom-4 tablet:margin-bottom-0"> <div class="margin-bottom-4 tablet:margin-bottom-0">
<nav aria-label=""> <nav aria-label="">
<h2 class="margin-top-0 text-semibold">{{ portfolio.organization_name }}</h2> <h2 class="margin-top-0 text-semibold">{{ portfolio.organization_name }}</h2>
<ul class="usa-sidenav"> <ul class="usa-sidenav usa-sidenav--portfolio">
<li class="usa-sidenav__item"> <li class="usa-sidenav__item">
{% url 'portfolio-domains' portfolio.id as url %} {% url 'portfolio-domains' portfolio.id as url %}
<a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}> <a href="{{ url }}" {% if request.path == url %}class="usa-current"{% endif %}>

View file

@ -2262,6 +2262,7 @@ class TestDomainRequestAdmin(MockEppLib):
"action_needed_reason_email", "action_needed_reason_email",
"federal_agency", "federal_agency",
"portfolio", "portfolio",
"sub_organization",
"creator", "creator",
"investigator", "investigator",
"generic_org_type", "generic_org_type",
@ -3574,7 +3575,7 @@ class TestMyUserAdmin(MockDb):
) )
}, },
), ),
("Personal Info", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}), ("User profile", {"fields": ("first_name", "middle_name", "last_name", "title", "email", "phone")}),
("Permissions", {"fields": ("is_active", "groups")}), ("Permissions", {"fields": ("is_active", "groups")}),
("Important dates", {"fields": ("last_login", "date_joined")}), ("Important dates", {"fields": ("last_login", "date_joined")}),
) )
@ -4065,9 +4066,7 @@ class TestContactAdmin(TestCase):
readonly_fields = self.admin.get_readonly_fields(request) readonly_fields = self.admin.get_readonly_fields(request)
expected_fields = [ expected_fields = ["user", "email"]
"user",
]
self.assertEqual(readonly_fields, expected_fields) self.assertEqual(readonly_fields, expected_fields)

View file

@ -735,7 +735,6 @@ class ExportDataTest(MockDb, MockEppLib):
csv_file.seek(0) csv_file.seek(0)
# Read the content into a variable # Read the content into a variable
csv_content = csv_file.read() csv_content = csv_file.read()
self.maxDiff = None
expected_content = ( expected_content = (
# Header # Header
"Domain request,Submitted at,Status,Domain type,Federal type," "Domain request,Submitted at,Status,Domain type,Federal type,"

View file

@ -965,7 +965,7 @@ class PortfoliosTests(TestWithUser, WebTest):
# Assert that we're on the right page # Assert that we're on the right page
self.assertContains(portfolio_page, self.portfolio.organization_name) self.assertContains(portfolio_page, self.portfolio.organization_name)
self.assertContains(portfolio_page, "<h1>Domains</h1>") self.assertContains(portfolio_page, '<h1 id="domains-header">Domains</h1>')
@less_console_noise_decorator @less_console_noise_decorator
def test_no_redirect_when_org_flag_false(self): def test_no_redirect_when_org_flag_false(self):

View file

@ -3,6 +3,7 @@ from django.urls import reverse
from .test_views import TestWithUser from .test_views import TestWithUser
from django_webtest import WebTest # type: ignore from django_webtest import WebTest # type: ignore
from django.utils.dateparse import parse_date from django.utils.dateparse import parse_date
from api.tests.common import less_console_noise_decorator
class GetDomainsJsonTest(TestWithUser, WebTest): class GetDomainsJsonTest(TestWithUser, WebTest):
@ -11,9 +12,9 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.app.set_user(self.user.username) self.app.set_user(self.user.username)
# Create test domains # Create test domains
self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="active") self.domain1 = Domain.objects.create(name="example1.com", expiration_date="2024-01-01", state="unknown")
self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="inactive") self.domain2 = Domain.objects.create(name="example2.com", expiration_date="2024-02-01", state="dns needed")
self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="active") self.domain3 = Domain.objects.create(name="example3.com", expiration_date="2024-03-01", state="ready")
# Create UserDomainRoles # Create UserDomainRoles
UserDomainRole.objects.create(user=self.user, domain=self.domain1) UserDomainRole.objects.create(user=self.user, domain=self.domain1)
@ -25,6 +26,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
UserDomainRole.objects.all().delete() UserDomainRole.objects.all().delete()
@less_console_noise_decorator
def test_get_domains_json_unauthenticated(self): def test_get_domains_json_unauthenticated(self):
"""for an unauthenticated user, test that the user is redirected for auth""" """for an unauthenticated user, test that the user is redirected for auth"""
self.app.reset() self.app.reset()
@ -32,6 +34,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
response = self.client.get(reverse("get_domains_json")) response = self.client.get(reverse("get_domains_json"))
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
@less_console_noise_decorator
def test_get_domains_json_authenticated(self): def test_get_domains_json_authenticated(self):
"""Test that an authenticated user gets the list of 3 domains.""" """Test that an authenticated user gets the list of 3 domains."""
response = self.app.get(reverse("get_domains_json")) response = self.app.get(reverse("get_domains_json"))
@ -102,6 +105,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
) )
self.assertEqual(svg_icon_expected, svg_icons[i]) self.assertEqual(svg_icon_expected, svg_icons[i])
@less_console_noise_decorator
def test_get_domains_json_search(self): def test_get_domains_json_search(self):
"""Test search.""" """Test search."""
# Define your URL variables as a dictionary # Define your URL variables as a dictionary
@ -131,6 +135,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
domains[0], domains[0],
) )
@less_console_noise_decorator
def test_pagination(self): def test_pagination(self):
"""Test that pagination is correct in the response""" """Test that pagination is correct in the response"""
response = self.app.get(reverse("get_domains_json"), {"page": 1}) response = self.app.get(reverse("get_domains_json"), {"page": 1})
@ -143,6 +148,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
self.assertFalse(data["has_previous"]) self.assertFalse(data["has_previous"])
self.assertEqual(data["num_pages"], 1) self.assertEqual(data["num_pages"], 1)
@less_console_noise_decorator
def test_sorting(self): def test_sorting(self):
"""test that sorting works properly in the response""" """test that sorting works properly in the response"""
response = self.app.get(reverse("get_domains_json"), {"sort_by": "expiration_date", "order": "desc"}) response = self.app.get(reverse("get_domains_json"), {"sort_by": "expiration_date", "order": "desc"})
@ -161,6 +167,7 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
expiration_dates = [domain["expiration_date"] for domain in data["domains"]] expiration_dates = [domain["expiration_date"] for domain in data["domains"]]
self.assertEqual(expiration_dates, sorted(expiration_dates)) self.assertEqual(expiration_dates, sorted(expiration_dates))
@less_console_noise_decorator
def test_sorting_by_state_display(self): def test_sorting_by_state_display(self):
"""test that the state_display sorting works properly""" """test that the state_display sorting works properly"""
response = self.app.get(reverse("get_domains_json"), {"sort_by": "state_display", "order": "asc"}) response = self.app.get(reverse("get_domains_json"), {"sort_by": "state_display", "order": "asc"})
@ -178,3 +185,21 @@ class GetDomainsJsonTest(TestWithUser, WebTest):
# Check if sorted by state_display in descending order # Check if sorted by state_display in descending order
states = [domain["state_display"] for domain in data["domains"]] states = [domain["state_display"] for domain in data["domains"]]
self.assertEqual(states, sorted(states, reverse=True)) self.assertEqual(states, sorted(states, reverse=True))
@less_console_noise_decorator
def test_state_filtering(self):
"""Test that different states in request get expected responses."""
expected_values = [
("unknown", 1),
("ready", 0),
("expired", 2),
("ready,expired", 2),
("unknown,expired", 3),
]
for state, num_domains in expected_values:
with self.subTest(state=state, num_domains=num_domains):
response = self.app.get(reverse("get_domains_json"), {"status": state})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domains"]), num_domains)

View file

@ -20,11 +20,46 @@ def get_domains_json(request):
# Handle sorting # Handle sorting
sort_by = request.GET.get("sort_by", "id") # Default to 'id' sort_by = request.GET.get("sort_by", "id") # Default to 'id'
order = request.GET.get("order", "asc") # Default to 'asc' order = request.GET.get("order", "asc") # Default to 'asc'
search_term = request.GET.get("search_term")
# Handle search term
search_term = request.GET.get("search_term")
if search_term: if search_term:
objects = objects.filter(Q(name__icontains=search_term)) objects = objects.filter(Q(name__icontains=search_term))
# Handle state
status_param = request.GET.get("status")
if status_param:
status_list = status_param.split(",")
# if unknown is in status_list, append 'dns needed' since both
# unknown and dns needed display as DNS Needed, and both are
# searchable via state parameter of 'unknown'
if "unknown" in status_list:
status_list.append("dns needed")
# Split the status list into normal states and custom states
normal_states = [state for state in status_list if state in Domain.State.values]
custom_states = [state for state in status_list if state == "expired"]
# Construct Q objects for normal states that can be queried through ORM
state_query = Q()
if normal_states:
state_query |= Q(state__in=normal_states)
# Handle custom states in Python, as expired can not be queried through ORM
if "expired" in custom_states:
expired_domain_ids = [domain.id for domain in objects if domain.state_display() == "Expired"]
state_query |= Q(id__in=expired_domain_ids)
# Apply the combined query
objects = objects.filter(state_query)
# If there are filtered states, and expired is not one of them, domains with
# state_display of 'Expired' must be removed
if "expired" not in custom_states:
expired_domain_ids = [domain.id for domain in objects if domain.state_display() == "Expired"]
objects = objects.exclude(id__in=expired_domain_ids)
if sort_by == "state_display": if sort_by == "state_display":
# Fetch the objects and sort them in Python # Fetch the objects and sort them in Python
objects = list(objects) # Evaluate queryset to a list objects = list(objects) # Evaluate queryset to a list

View file

@ -68,6 +68,8 @@
10038 OUTOFSCOPE http://app:8080/dns/dnssec 10038 OUTOFSCOPE http://app:8080/dns/dnssec
10038 OUTOFSCOPE http://app:8080/dns/dnssec/dsdata 10038 OUTOFSCOPE http://app:8080/dns/dnssec/dsdata
10038 OUTOFSCOPE http://app:8080/org-name-address 10038 OUTOFSCOPE http://app:8080/org-name-address
10038 OUTOFSCOPE http://app:8080/domain_requests/
10038 OUTOFSCOPE http://app:8080/domains/
# This URL always returns 404, so include it as well. # This URL always returns 404, so include it as well.
10038 OUTOFSCOPE http://app:8080/todo 10038 OUTOFSCOPE http://app:8080/todo
# OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers # OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers