Merge pull request #2739 from cisagov/dk/2593-domain-request-search-bar

#2593: Domain request search bar and filter - [DK]
This commit is contained in:
zandercymatics 2024-09-19 08:24:36 -06:00 committed by GitHub
commit 8adb33b430
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 975 additions and 735 deletions

View file

@ -33,6 +33,14 @@ const showElement = (element) => {
element.classList.remove('display-none'); element.classList.remove('display-none');
}; };
/**
* Helper function to get the CSRF token from the cookie
*
*/
function getCsrfToken() {
return document.querySelector('input[name="csrfmiddlewaretoken"]').value;
}
/** /**
* Helper function that scrolls to an element * Helper function that scrolls to an element
* @param {string} attributeName - The string "class" or "id" * @param {string} attributeName - The string "class" or "id"
@ -994,40 +1002,79 @@ function unloadModals() {
window.modal.off(); window.modal.off();
} }
class LoadTableBase {
constructor(tableSelector, tableWrapperSelector, searchFieldId, searchSubmitId, resetSearchBtn, resetFiltersBtn, noDataDisplay, noSearchresultsDisplay) {
this.tableWrapper = document.querySelector(tableWrapperSelector);
this.tableHeaders = document.querySelectorAll(`${tableSelector} th[data-sortable]`);
this.currentSortBy = 'id';
this.currentOrder = 'asc';
this.currentStatus = [];
this.currentSearchTerm = '';
this.scrollToTable = false;
this.searchInput = document.querySelector(searchFieldId);
this.searchSubmit = document.querySelector(searchSubmitId);
this.tableAnnouncementRegion = document.querySelector(`${tableWrapperSelector} .usa-table__announcement-region`);
this.resetSearchButton = document.querySelector(resetSearchBtn);
this.resetFiltersButton = document.querySelector(resetFiltersBtn);
// NOTE: these 3 can't be used if filters are active on a page with more than 1 table
this.statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
this.statusIndicator = document.querySelector('.filter-indicator');
this.statusToggle = document.querySelector('.usa-button--filter');
this.noTableWrapper = document.querySelector(noDataDisplay);
this.noSearchResultsWrapper = document.querySelector(noSearchresultsDisplay);
this.portfolioElement = document.getElementById('portfolio-js-value');
this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null;
this.initializeTableHeaders();
this.initializeSearchHandler();
this.initializeStatusToggleHandler();
this.initializeFilterCheckboxes();
this.initializeResetSearchButton();
this.initializeResetFiltersButton();
this.initializeAccordionAccessibilityListeners();
}
/** /**
* Generalized function to update pagination for a list. * Generalized function to update pagination for a list.
* @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} linkAnchor - CSS selector for the header element to anchor the links to. * @param {string} tableSelector - CSS selector for the header element to anchor the links to.
* @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.
* @param {boolean} hasPrevious - Whether there is a page before the current page. * @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 {boolean} hasNext - Whether there is a page after the current page.
* @param {number} totalItems - The total number of items. * @param {number} total - The total number of items.
* @param {string} searchTerm - The search term
*/ */
function updatePagination(itemName, paginationSelector, counterSelector, linkAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) { updatePagination(
const paginationContainer = document.querySelector(paginationSelector); itemName,
const paginationCounter = document.querySelector(counterSelector); paginationSelector,
counterSelector,
parentTableSelector,
currentPage,
numPages,
hasPrevious,
hasNext,
totalItems,
) {
const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`); const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`);
paginationCounter.innerHTML = ''; const counterSelectorEl = document.querySelector(counterSelector);
const paginationSelectorEl = document.querySelector(paginationSelector);
counterSelectorEl.innerHTML = '';
paginationButtons.innerHTML = ''; paginationButtons.innerHTML = '';
// Buttons should only be displayed if there are more than one pages of results // Buttons should only be displayed if there are more than one pages of results
paginationButtons.classList.toggle('display-none', numPages <= 1); paginationButtons.classList.toggle('display-none', numPages <= 1);
// Counter should only be displayed if there is more than 1 item // Counter should only be displayed if there is more than 1 item
paginationContainer.classList.toggle('display-none', totalItems < 1); paginationSelectorEl.classList.toggle('display-none', totalItems < 1);
paginationCounter.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}${searchTerm ? ' for ' + '"' + searchTerm + '"' : ''}`; counterSelectorEl.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`;
if (hasPrevious) { if (hasPrevious) {
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="${linkAnchor}" class="usa-pagination__link usa-pagination__previous-page" aria-label="Previous page"> <a href="${parentTableSelector}" 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>
@ -1036,32 +1083,14 @@ function updatePagination(itemName, paginationSelector, counterSelector, linkAnc
`; `;
prevPageItem.querySelector('a').addEventListener('click', (event) => { prevPageItem.querySelector('a').addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault();
loadPageFunction(currentPage - 1); this.loadTable(currentPage - 1);
}); });
paginationButtons.appendChild(prevPageItem); paginationButtons.appendChild(prevPageItem);
} }
// Helper function to create a page item
function createPageItem(page) {
const pageItem = document.createElement('li');
pageItem.className = 'usa-pagination__item usa-pagination__page-no';
pageItem.innerHTML = `
<a href="${linkAnchor}" class="usa-pagination__button" aria-label="Page ${page}">${page}</a>
`;
if (page === currentPage) {
pageItem.querySelector('a').classList.add('usa-current');
pageItem.querySelector('a').setAttribute('aria-current', 'page');
}
pageItem.querySelector('a').addEventListener('click', (event) => {
event.preventDefault();
loadPageFunction(page);
});
return pageItem;
}
// Add first page and ellipsis if necessary // Add first page and ellipsis if necessary
if (currentPage > 2) { if (currentPage > 2) {
paginationButtons.appendChild(createPageItem(1)); paginationButtons.appendChild(this.createPageItem(1, parentTableSelector, currentPage));
if (currentPage > 3) { if (currentPage > 3) {
const ellipsis = document.createElement('li'); const ellipsis = document.createElement('li');
ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; ellipsis.className = 'usa-pagination__item usa-pagination__overflow';
@ -1073,7 +1102,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, linkAnc
// Add pages around the current page // Add pages around the current page
for (let i = Math.max(1, currentPage - 1); i <= Math.min(numPages, currentPage + 1); i++) { for (let i = Math.max(1, currentPage - 1); i <= Math.min(numPages, currentPage + 1); i++) {
paginationButtons.appendChild(createPageItem(i)); paginationButtons.appendChild(this.createPageItem(i, parentTableSelector, currentPage));
} }
// Add last page and ellipsis if necessary // Add last page and ellipsis if necessary
@ -1085,14 +1114,14 @@ function updatePagination(itemName, paginationSelector, counterSelector, linkAnc
ellipsis.innerHTML = '<span>…</span>'; ellipsis.innerHTML = '<span>…</span>';
paginationButtons.appendChild(ellipsis); paginationButtons.appendChild(ellipsis);
} }
paginationButtons.appendChild(createPageItem(numPages)); paginationButtons.appendChild(this.createPageItem(numPages, parentTableSelector, currentPage));
} }
if (hasNext) { if (hasNext) {
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="${linkAnchor}" class="usa-pagination__link usa-pagination__next-page" aria-label="Next page"> <a href="${parentTableSelector}" 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>
@ -1101,7 +1130,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, linkAnc
`; `;
nextPageItem.querySelector('a').addEventListener('click', (event) => { nextPageItem.querySelector('a').addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault();
loadPageFunction(currentPage + 1); this.loadTable(currentPage + 1);
}); });
paginationButtons.appendChild(nextPageItem); paginationButtons.appendChild(nextPageItem);
} }
@ -1111,7 +1140,7 @@ function updatePagination(itemName, paginationSelector, counterSelector, linkAnc
* 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) => { updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => {
const { unfiltered_total, total } = data; const { unfiltered_total, total } = data;
if (unfiltered_total) { if (unfiltered_total) {
if (total) { if (total) {
@ -1130,11 +1159,29 @@ const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper)
} }
}; };
// Helper function to create a page item
createPageItem(page, parentTableSelector, currentPage) {
const pageItem = document.createElement('li');
pageItem.className = 'usa-pagination__item usa-pagination__page-no';
pageItem.innerHTML = `
<a href="${parentTableSelector}" class="usa-pagination__button" aria-label="Page ${page}">${page}</a>
`;
if (page === currentPage) {
pageItem.querySelector('a').classList.add('usa-current');
pageItem.querySelector('a').setAttribute('aria-current', 'page');
}
pageItem.querySelector('a').addEventListener('click', (event) => {
event.preventDefault();
this.loadTable(page);
});
return pageItem;
}
/** /**
* A helper that resets sortable table headers * A helper that resets sortable table headers
* *
*/ */
const unsetHeader = (header) => { unsetHeader = (header) => {
header.removeAttribute('aria-sort'); header.removeAttribute('aria-sort');
let headerName = header.innerText; let headerName = header.innerText;
const headerLabel = `${headerName}, sortable column, currently unsorted"`; const headerLabel = `${headerName}, sortable column, currently unsorted"`;
@ -1143,34 +1190,181 @@ const unsetHeader = (header) => {
header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel); header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel);
}; };
/** // Abstract method (to be implemented in the child class)
* An IIFE that listens for DOM Content to be loaded, then executes. This function loadTable(page, sortBy, order) {
* initializes the domains list and associated functionality on the home page of the app. throw new Error('loadData() must be implemented in a subclass');
* }
*/
document.addEventListener('DOMContentLoaded', function() {
const domainsWrapper = document.querySelector('.domains__table-wrapper');
if (domainsWrapper) { // Add event listeners to table headers for sorting
let currentSortBy = 'id'; initializeTableHeaders() {
let currentOrder = 'asc'; this.tableHeaders.forEach(header => {
const noDomainsWrapper = document.querySelector('.domains__no-data'); header.addEventListener('click', () => {
const noSearchResultsWrapper = document.querySelector('.domains__no-search-results'); const sortBy = header.getAttribute('data-sortable');
let scrollToTable = false; let order = 'asc';
let currentStatus = []; // sort order will be ascending, unless the currently sorted column is ascending, and the user
let currentSearchTerm = ''; // is selecting the same column to sort in descending order
const domainsSearchInput = document.getElementById('domains__search-field'); if (sortBy === this.currentSortBy) {
const domainsSearchSubmit = document.getElementById('domains__search-field-submit'); order = this.currentOrder === 'asc' ? 'desc' : 'asc';
const tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]'); }
const tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region'); // load the results with the updated sort
const resetSearchButton = document.querySelector('.domains__reset-search'); this.loadTable(1, sortBy, order);
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');
const portfolioElement = document.getElementById('portfolio-js-value');
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
initializeSearchHandler() {
this.searchSubmit.addEventListener('click', (e) => {
e.preventDefault();
this.currentSearchTerm = this.searchInput.value;
// If the search is blank, we match the resetSearch functionality
if (this.currentSearchTerm) {
showElement(this.resetSearchButton);
} else {
hideElement(this.resetSearchButton);
}
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
});
}
initializeStatusToggleHandler() {
if (this.statusToggle) {
this.statusToggle.addEventListener('click', () => {
toggleCaret(this.statusToggle);
});
}
}
// Add event listeners to status filter checkboxes
initializeFilterCheckboxes() {
this.statusCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', () => {
const checkboxValue = checkbox.value;
// Update currentStatus array based on checkbox state
if (checkbox.checked) {
this.currentStatus.push(checkboxValue);
} else {
const index = this.currentStatus.indexOf(checkboxValue);
if (index > -1) {
this.currentStatus.splice(index, 1);
}
}
// Manage visibility of reset filters button
if (this.currentStatus.length == 0) {
hideElement(this.resetFiltersButton);
} else {
showElement(this.resetFiltersButton);
}
// Disable the auto scroll
this.scrollToTable = false;
// Call loadTable with updated status
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
this.updateStatusIndicator();
});
});
}
// Reset UI and accessibility
resetHeaders() {
this.tableHeaders.forEach(header => {
// Unset sort UI in headers
this.unsetHeader(header);
});
// Reset the announcement region
this.tableAnnouncementRegion.innerHTML = '';
}
resetSearch() {
this.searchInput.value = '';
this.currentSearchTerm = '';
hideElement(this.resetSearchButton);
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
}
initializeResetSearchButton() {
if (this.resetSearchButton) {
this.resetSearchButton.addEventListener('click', () => {
this.resetSearch();
});
}
}
resetFilters() {
this.currentStatus = [];
this.statusCheckboxes.forEach(checkbox => {
checkbox.checked = false;
});
hideElement(this.resetFiltersButton);
// Disable the auto scroll
this.scrollToTable = false;
this.loadTable(1, 'id', 'asc');
this.resetHeaders();
this.updateStatusIndicator();
// No need to toggle close the filters. The focus shift will trigger that for us.
}
initializeResetFiltersButton() {
if (this.resetFiltersButton) {
this.resetFiltersButton.addEventListener('click', () => {
this.resetFilters();
});
}
}
updateStatusIndicator() {
this.statusIndicator.innerHTML = '';
// Even if the element is empty, it'll mess up the flex layout unless we set display none
hideElement(this.statusIndicator);
if (this.currentStatus.length)
this.statusIndicator.innerHTML = '(' + this.currentStatus.length + ')';
showElement(this.statusIndicator);
}
closeFilters() {
if (this.statusToggle.getAttribute("aria-expanded") === "true") {
this.statusToggle.click();
}
}
initializeAccordionAccessibilityListeners() {
// 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', (event) => {
const accordion = document.querySelector('.usa-accordion--select');
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionThatIsOpen && !accordion.contains(event.target)) {
this.closeFilters();
}
});
// Close when user clicks outside
// NOTE: We may need to evolve this as we add more filters.
document.addEventListener('click', (event) => {
const accordion = document.querySelector('.usa-accordion--select');
const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionThatIsOpen && !accordion.contains(event.target)) {
this.closeFilters();
}
});
}
}
class DomainsTable extends LoadTableBase {
constructor() {
super('.domains__table', '.domains__table-wrapper', '#domains__search-field', '#domains__search-field-submit', '.domains__reset-search', '.domains__reset-filters', '.domains__no-data', '.domains__no-search-results');
}
/** /**
* 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
* based on the supplied attributes. * based on the supplied attributes.
@ -1178,10 +1372,12 @@ document.addEventListener('DOMContentLoaded', function() {
* @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 {*} scroll - control for the scrollToElement functionality * @param {*} scroll - control for the scrollToElement functionality
* @param {*} status - control for the status filter
* @param {*} searchTerm - the search term * @param {*} searchTerm - the search term
* @param {*} portfolio - the portfolio id * @param {*} portfolio - the portfolio id
*/ */
function loadDomains(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, status = currentStatus, searchTerm = currentSearchTerm, portfolio = portfolioValue) { loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
// fetch json of page of domais, given params // fetch json of page of domais, given params
let baseUrl = document.getElementById("get_domains_json_url"); let baseUrl = document.getElementById("get_domains_json_url");
if (!baseUrl) { if (!baseUrl) {
@ -1194,10 +1390,19 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// fetch json of page of domains, given params // fetch json of page of domains, given params
let url = `${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&status=${status}&search_term=${searchTerm}` let searchParams = new URLSearchParams(
{
"page": page,
"sort_by": sortBy,
"order": order,
"status": status,
"search_term": searchTerm
}
);
if (portfolio) if (portfolio)
url += `&portfolio=${portfolio}` searchParams.append("portfolio", portfolio)
let url = `${baseUrlValue}?${searchParams.toString()}`
fetch(url) fetch(url)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
@ -1207,7 +1412,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// 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, currentSearchTerm); this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.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');
@ -1225,7 +1430,7 @@ document.addEventListener('DOMContentLoaded', function() {
let markupForSuborganizationRow = ''; let markupForSuborganizationRow = '';
if (portfolioValue) { if (this.portfolioValue) {
markupForSuborganizationRow = ` markupForSuborganizationRow = `
<td> <td>
<span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span> <span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
@ -1271,271 +1476,46 @@ document.addEventListener('DOMContentLoaded', function() {
// Do not scroll on first page load // Do not scroll on first page load
if (scroll) if (scroll)
ScrollToElement('class', 'domains'); ScrollToElement('class', 'domains');
scrollToTable = true; this.scrollToTable = true;
// update pagination // update pagination
updatePagination( this.updatePagination(
'domain', 'domain',
'#domains-pagination', '#domains-pagination',
'#domains-pagination .usa-pagination__counter', '#domains-pagination .usa-pagination__counter',
'#domains', '#domains',
loadDomains,
data.page, data.page,
data.num_pages, data.num_pages,
data.has_previous, data.has_previous,
data.has_next, data.has_next,
data.total, data.total,
currentSearchTerm
); );
currentSortBy = sortBy; this.currentSortBy = sortBy;
currentOrder = order; this.currentOrder = order;
currentSearchTerm = searchTerm; this.currentSearchTerm = searchTerm;
}) })
.catch(error => console.error('Error fetching domains:', error)); .catch(error => console.error('Error fetching domains:', error));
} }
// Add event listeners to table headers for sorting
tableHeaders.forEach(header => {
header.addEventListener('click', function() {
const sortBy = this.getAttribute('data-sortable');
let order = 'asc';
// sort order will be ascending, unless the currently sorted column is ascending, and the user
// is selecting the same column to sort in descending order
if (sortBy === currentSortBy) {
order = currentOrder === 'asc' ? 'desc' : 'asc';
}
// load the results with the updated sort
loadDomains(1, sortBy, order);
});
});
domainsSearchSubmit.addEventListener('click', function(e) {
e.preventDefault();
currentSearchTerm = domainsSearchInput.value;
// If the search is blank, we match the resetSearch functionality
if (currentSearchTerm) {
showElement(resetSearchButton);
} else {
hideElement(resetSearchButton);
}
loadDomains(1, 'id', 'asc');
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 class DomainRequestsTable extends LoadTableBase {
if (this.checked) {
currentStatus.push(checkboxValue); constructor() {
} else { super('.domain-requests__table', '.domain-requests__table-wrapper', '#domain-requests__search-field', '#domain-requests__search-field-submit', '.domain-requests__reset-search', '.domain-requests__reset-filters', '.domain-requests__no-data', '.domain-requests__no-search-results');
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
function resetHeaders() {
tableHeaders.forEach(header => {
// Unset sort UI in headers
unsetHeader(header);
});
// Reset the announcement region
tableAnnouncementRegion.innerHTML = '';
}
function resetSearch() {
domainsSearchInput.value = '';
currentSearchTerm = '';
hideElement(resetSearchButton);
loadDomains(1, 'id', 'asc');
resetHeaders();
}
if (resetSearchButton) {
resetSearchButton.addEventListener('click', function() {
resetSearch();
});
}
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 accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionThatIsOpen && !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 accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]');
if (accordionThatIsOpen && !accordion.contains(event.target)) {
closeFilters();
}
});
// Initial load
loadDomains(1);
}
});
const utcDateString = (dateString) => {
const date = new Date(dateString);
const utcYear = date.getUTCFullYear();
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
const utcDay = date.getUTCDate().toString().padStart(2, '0');
let utcHours = date.getUTCHours();
const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0');
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 * Loads rows in the domains list, as well as updates pagination around the domains list
* initializes the domain requests list and associated functionality on the home page of the app.
*
*/
document.addEventListener('DOMContentLoaded', function() {
const domainRequestsSectionWrapper = document.querySelector('.domain-requests');
const domainRequestsWrapper = document.querySelector('.domain-requests__table-wrapper');
if (domainRequestsWrapper) {
let currentSortBy = 'id';
let currentOrder = 'asc';
const noDomainRequestsWrapper = document.querySelector('.domain-requests__no-data');
const noSearchResultsWrapper = document.querySelector('.domain-requests__no-search-results');
let scrollToTable = 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 resetSearchButton = document.querySelector('.domain-requests__reset-search');
const portfolioElement = document.getElementById('portfolio-js-value');
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
/**
* 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, scrollToTable, 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
* based on the supplied attributes. * based on the supplied attributes.
* @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 {*} scroll - control for the scrollToElement functionality * @param {*} scroll - control for the scrollToElement functionality
* @param {*} status - control for the status filter
* @param {*} searchTerm - the search term * @param {*} searchTerm - the search term
* @param {*} portfolio - the portfolio id
*/ */
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm, portfolio = portfolioValue) { loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) {
// fetch json of page of domain requests, given params
let baseUrl = document.getElementById("get_domain_requests_json_url"); let baseUrl = document.getElementById("get_domain_requests_json_url");
if (!baseUrl) { if (!baseUrl) {
return; return;
@ -1546,11 +1526,20 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
// fetch json of page of requests, given params // add searchParams
let url = `${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}` let searchParams = new URLSearchParams(
{
"page": page,
"sort_by": sortBy,
"order": order,
"status": status,
"search_term": searchTerm
}
);
if (portfolio) if (portfolio)
url += `&portfolio=${portfolio}` searchParams.append("portfolio", portfolio)
let url = `${baseUrlValue}?${searchParams.toString()}`
fetch(url) fetch(url)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
@ -1560,7 +1549,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// 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, currentSearchTerm); this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.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');
@ -1613,7 +1602,7 @@ document.addEventListener('DOMContentLoaded', function() {
let markupCreatorRow = ''; let markupCreatorRow = '';
if (portfolioValue) { if (this.portfolioValue) {
markupCreatorRow = ` markupCreatorRow = `
<td> <td>
<span class="text-wrap break-word">${request.creator ? request.creator : ''}</span> <span class="text-wrap break-word">${request.creator ? request.creator : ''}</span>
@ -1708,10 +1697,10 @@ document.addEventListener('DOMContentLoaded', function() {
</div> </div>
` `
domainRequestsSectionWrapper.appendChild(modal); this.tableWrapper.appendChild(modal);
// Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly // Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly
if (portfolioValue) { if (this.portfolioValue) {
modalTrigger = ` modalTrigger = `
<a <a
role="button" role="button"
@ -1794,8 +1783,8 @@ document.addEventListener('DOMContentLoaded', function() {
modals.forEach(modal => { modals.forEach(modal => {
const submitButton = modal.querySelector('.usa-modal__submit'); const submitButton = modal.querySelector('.usa-modal__submit');
const closeButton = modal.querySelector('.usa-modal__close'); const closeButton = modal.querySelector('.usa-modal__close');
submitButton.addEventListener('click', function() { submitButton.addEventListener('click', () => {
pk = submitButton.getAttribute('data-pk'); let pk = submitButton.getAttribute('data-pk');
// Close the modal to remove the USWDS UI local classes // Close the modal to remove the USWDS UI local classes
closeButton.click(); closeButton.click();
// If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page
@ -1803,90 +1792,95 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.total == 1 && data.unfiltered_total > 1) { if (data.total == 1 && data.unfiltered_total > 1) {
pageToDisplay--; pageToDisplay--;
} }
deleteDomainRequest(pk, pageToDisplay); this.deleteDomainRequest(pk, pageToDisplay);
}); });
}); });
// Do not scroll on first page load // Do not scroll on first page load
if (scroll) if (scroll)
ScrollToElement('class', 'domain-requests'); ScrollToElement('class', 'domain-requests');
scrollToTable = true; this.scrollToTable = true;
// update the pagination after the domain requests list is updated // update the pagination after the domain requests list is updated
updatePagination( this.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', '#domain-requests',
loadDomainRequests,
data.page, data.page,
data.num_pages, data.num_pages,
data.has_previous, data.has_previous,
data.has_next, data.has_next,
data.total, data.total,
currentSearchTerm
); );
currentSortBy = sortBy; this.currentSortBy = sortBy;
currentOrder = order; this.currentOrder = order;
currentSearchTerm = searchTerm; this.currentSearchTerm = searchTerm;
}) })
.catch(error => console.error('Error fetching domain requests:', error)); .catch(error => console.error('Error fetching domain requests:', error));
} }
// Add event listeners to table headers for sorting /**
tableHeaders.forEach(header => { * 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.
header.addEventListener('click', function() { * @param {*} domainRequestPk - the identifier for the request that we're deleting
const sortBy = this.getAttribute('data-sortable'); * @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
let order = 'asc'; */
// sort order will be ascending, unless the currently sorted column is ascending, and the user deleteDomainRequest(domainRequestPk, pageToDisplay) {
// is selecting the same column to sort in descending order // Use to debug uswds modal issues
if (sortBy === currentSortBy) { //console.log('deleteDomainRequest')
order = currentOrder === 'asc' ? 'desc' : 'asc';
}
loadDomainRequests(1, sortBy, order);
});
});
domainRequestsSearchSubmit.addEventListener('click', function(e) { // Get csrf token
e.preventDefault(); const csrfToken = getCsrfToken();
currentSearchTerm = domainRequestsSearchInput.value; // Create FormData object and append the CSRF token
// If the search is blank, we match the resetSearch functionality const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}&delete-domain-request=`;
if (currentSearchTerm) {
showElement(resetSearchButton);
} else {
hideElement(resetSearchButton);
}
loadDomainRequests(1, 'id', 'asc');
resetHeaders();
});
// Reset UI and accessibility fetch(`/domain-request/${domainRequestPk}/delete`, {
function resetHeaders() { method: 'POST',
tableHeaders.forEach(header => { headers: {
// unset sort UI in headers 'Content-Type': 'application/x-www-form-urlencoded',
unsetHeader(header); 'X-CSRFToken': csrfToken,
}); },
// Reset the announcement region body: formData
tableAnnouncementRegion.innerHTML = ''; })
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Update data and UI
this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentSearchTerm);
})
.catch(error => console.error('Error fetching domain requests:', error));
}
} }
function resetSearch() {
domainRequestsSearchInput.value = '';
currentSearchTerm = '';
hideElement(resetSearchButton);
loadDomainRequests(1, 'id', 'asc');
resetHeaders();
}
if (resetSearchButton) { /**
resetSearchButton.addEventListener('click', function() { * An IIFE that listens for DOM Content to be loaded, then executes. This function
resetSearch(); * initializes the domains list and associated functionality on the home page of the app.
*
*/
document.addEventListener('DOMContentLoaded', function() {
const isDomainsPage = document.querySelector("#domains")
if (isDomainsPage){
const domainsTable = new DomainsTable();
if (domainsTable.tableWrapper) {
// Initial load
domainsTable.loadTable(1);
}
}
}); });
}
function closeMoreActionMenu(accordionThatIsOpen) { /**
if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") { * An IIFE that listens for DOM Content to be loaded, then executes. This function
accordionThatIsOpen.click(); * initializes the domain requests list and associated functionality on the home page of the app.
*
*/
document.addEventListener('DOMContentLoaded', function() {
const domainRequestsSectionWrapper = document.querySelector('.domain-requests');
if (domainRequestsSectionWrapper) {
const domainRequestsTable = new DomainRequestsTable();
if (domainRequestsTable.tableWrapper) {
domainRequestsTable.loadTable(1);
} }
} }
@ -1898,6 +1892,12 @@ document.addEventListener('DOMContentLoaded', function() {
closeOpenAccordions(event); closeOpenAccordions(event);
}); });
function closeMoreActionMenu(accordionThatIsOpen) {
if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") {
accordionThatIsOpen.click();
}
}
function closeOpenAccordions(event) { function closeOpenAccordions(event) {
const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]'); const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]');
openAccordions.forEach((openAccordionButton) => { openAccordions.forEach((openAccordionButton) => {
@ -1909,12 +1909,22 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
} }
// Initial load
loadDomainRequests(1);
}
}); });
const utcDateString = (dateString) => {
const date = new Date(dateString);
const utcYear = date.getUTCFullYear();
const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' });
const utcDay = date.getUTCDate().toString().padStart(2, '0');
let utcHours = date.getUTCHours();
const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0');
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 displays confirmation modal on the user profile page * An IIFE that displays confirmation modal on the user profile page

View file

@ -23,13 +23,21 @@
</svg> </svg>
Reset Reset
</button> </button>
{% if portfolio %}
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name or creator</label>
{% else %}
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label> <label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
{% endif %}
<input <input
class="usa-input" class="usa-input"
id="domain-requests__search-field" id="domain-requests__search-field"
type="search" type="search"
name="search" name="search"
{% if portfolio %}
placeholder="Search by domain name or creator"
{% else %}
placeholder="Search by domain name" placeholder="Search by domain name"
{% endif %}
/> />
<button class="usa-button" type="submit" id="domain-requests__search-field-submit"> <button class="usa-button" type="submit" id="domain-requests__search-field-submit">
<img <img
@ -42,6 +50,125 @@
</section> </section>
</div> </div>
</div> </div>
{% if portfolio %}
<div class="display-flex flex-align-center">
<span class="margin-right-2 margin-top-neg-1 usa-prose 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="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-started"
type="checkbox"
name="filter-status"
value="started"
/>
<label class="usa-checkbox__label" for="filter-status-started"
>Started</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-submitted"
type="checkbox"
name="filter-status"
value="submitted"
/>
<label class="usa-checkbox__label" for="filter-status-submitted"
>Submitted</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-in-review"
type="checkbox"
name="filter-status"
value="in review"
/>
<label class="usa-checkbox__label" for="filter-status-in-review"
>In review</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-action-needed"
type="checkbox"
name="filter-status"
value="action needed"
/>
<label class="usa-checkbox__label" for="filter-status-action-needed"
>Action needed</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-rejected"
type="checkbox"
name="filter-status"
value="rejected"
/>
<label class="usa-checkbox__label" for="filter-status-rejected"
>Rejected</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-withdrawn"
type="checkbox"
name="filter-status"
value="withdrawn"
/>
<label class="usa-checkbox__label" for="filter-status-withdrawn"
>Withdrawn</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-ineligible"
type="checkbox"
name="filter-status"
value="ineligible"
/>
<label class="usa-checkbox__label" for="filter-status-ineligible"
>Ineligible</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 domain-requests__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="domain-requests__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0"> <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>

View file

@ -64,7 +64,7 @@
aria-expanded="false" aria-expanded="false"
aria-controls="filter-status" aria-controls="filter-status"
> >
<span class="domain__filter-indicator text-bold display-none"></span> Status <span class="filter-indicator text-bold display-none"></span> Status
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24"> <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> <use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg> </svg>

View file

@ -458,3 +458,81 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
# Ensure no approved requests are included # Ensure no approved requests are included
for domain_request in data["domain_requests"]: for domain_request in data["domain_requests"]:
self.assertNotEqual(domain_request["status"], DomainRequest.DomainRequestStatus.APPROVED) self.assertNotEqual(domain_request["status"], DomainRequest.DomainRequestStatus.APPROVED)
def test_search(self):
"""Tests our search functionality. We expect that search filters on creator only when we are in a portfolio"""
# Test search for domain name
response = self.app.get(reverse("get_domain_requests_json"), {"search_term": "lamb"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domain_requests"]), 1)
requested_domain = data["domain_requests"][0]["requested_domain"]
self.assertEqual(requested_domain, "lamb-chops.gov")
# Test search for 'New domain request'
response = self.app.get(reverse("get_domain_requests_json"), {"search_term": "new domain"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(any(req["requested_domain"] is None for req in data["domain_requests"]))
# Test search with portfolio (including creator search)
self.client.force_login(self.user)
with override_flag("organization_feature", active=True), override_flag("organization_requests", active=True):
user_perm, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
response = self.app.get(
reverse("get_domain_requests_json"), {"search_term": "info", "portfolio": self.portfolio.id}
)
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(any(req["creator"].startswith("info") for req in data["domain_requests"]))
# Test search without portfolio (should not search on creator)
with override_flag("organization_feature", active=False), override_flag("organization_requests", active=False):
user_perm.delete()
response = self.app.get(reverse("get_domain_requests_json"), {"search_term": "info"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domain_requests"]), 0)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_status_filter(self):
"""Test that status filtering works properly"""
# Test a single status
response = self.app.get(reverse("get_domain_requests_json"), {"status": "started"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(all(req["status"] == "Started" for req in data["domain_requests"]))
# Test an invalid status
response = self.app.get(reverse("get_domain_requests_json"), {"status": "approved"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domain_requests"]), 0)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_combined_filtering_and_sorting(self):
"""Test that combining filters and sorting works properly"""
user_perm, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
self.client.force_login(self.user)
response = self.app.get(
reverse("get_domain_requests_json"),
{"search_term": "beef", "status": "started", "portfolio": self.portfolio.id},
)
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(all("beef" in req["requested_domain"] for req in data["domain_requests"]))
self.assertTrue(all(req["status"] == "Started" for req in data["domain_requests"]))
created_at_dates = [req["created_at"] for req in data["domain_requests"]]
self.assertEqual(created_at_dates, sorted(created_at_dates, reverse=True))
user_perm.delete()

View file

@ -20,6 +20,7 @@ def get_domain_requests_json(request):
unfiltered_total = objects.count() unfiltered_total = objects.count()
objects = apply_search(objects, request) objects = apply_search(objects, request)
objects = apply_status_filter(objects, request)
objects = apply_sorting(objects, request) objects = apply_sorting(objects, request)
paginator = Paginator(objects, 10) paginator = Paginator(objects, 10)
@ -63,6 +64,7 @@ def get_domain_request_ids_from_request(request):
def apply_search(queryset, request): def apply_search(queryset, request):
search_term = request.GET.get("search_term") search_term = request.GET.get("search_term")
is_portfolio = request.GET.get("portfolio")
if search_term: if search_term:
search_term_lower = search_term.lower() search_term_lower = search_term.lower()
@ -75,11 +77,34 @@ def apply_search(queryset, request):
queryset = queryset.filter( queryset = queryset.filter(
Q(requested_domain__name__icontains=search_term) | Q(requested_domain__isnull=True) Q(requested_domain__name__icontains=search_term) | Q(requested_domain__isnull=True)
) )
elif is_portfolio:
queryset = queryset.filter(
Q(requested_domain__name__icontains=search_term)
| Q(creator__first_name__icontains=search_term)
| Q(creator__last_name__icontains=search_term)
| Q(creator__email__icontains=search_term)
)
# For non org users
else: else:
queryset = queryset.filter(Q(requested_domain__name__icontains=search_term)) queryset = queryset.filter(Q(requested_domain__name__icontains=search_term))
return queryset return queryset
def apply_status_filter(queryset, request):
status_param = request.GET.get("status")
if status_param:
status_list = status_param.split(",")
statuses = [status for status in status_list if status in DomainRequest.DomainRequestStatus.values]
# Construct Q objects for statuses that can be queried through ORM
status_query = Q()
if statuses:
status_query |= Q(status__in=statuses)
# Apply the combined query
queryset = queryset.filter(status_query)
return queryset
def apply_sorting(queryset, request): def apply_sorting(queryset, request):
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'