diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index cd42fd322..027ef4344 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -33,6 +33,14 @@ const showElement = (element) => { 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 * @param {string} attributeName - The string "class" or "id" @@ -994,59 +1002,169 @@ function unloadModals() { 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. * @param {string} itemName - The name displayed in the counter * @param {string} paginationSelector - CSS selector for the pagination container. * @param {string} counterSelector - CSS selector for the pagination counter. - * @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 {string} tableSelector - CSS selector for the header element to anchor the links to. * @param {number} currentPage - The current page number (starting with 1). * @param {number} numPages - The total number of pages. * @param {boolean} hasPrevious - Whether there is a page before the current page. * @param {boolean} hasNext - Whether there is a page after the current page. - * @param {number} totalItems - The total number of items. - * @param {string} searchTerm - The search term - */ -function updatePagination(itemName, paginationSelector, counterSelector, linkAnchor, loadPageFunction, currentPage, numPages, hasPrevious, hasNext, totalItems, searchTerm) { - const paginationContainer = document.querySelector(paginationSelector); - const paginationCounter = document.querySelector(counterSelector); - const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`); - paginationCounter.innerHTML = ''; - paginationButtons.innerHTML = ''; + * @param {number} total - The total number of items. + */ + updatePagination( + itemName, + paginationSelector, + counterSelector, + parentTableSelector, + currentPage, + numPages, + hasPrevious, + hasNext, + totalItems, + ) { + const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`); + const counterSelectorEl = document.querySelector(counterSelector); + const paginationSelectorEl = document.querySelector(paginationSelector); + counterSelectorEl.innerHTML = ''; + paginationButtons.innerHTML = ''; - // Buttons should only be displayed if there are more than one pages of results - paginationButtons.classList.toggle('display-none', numPages <= 1); + // Buttons should only be displayed if there are more than one pages of results + paginationButtons.classList.toggle('display-none', numPages <= 1); - // Counter should only be displayed if there is more than 1 item - paginationContainer.classList.toggle('display-none', totalItems < 1); + // Counter should only be displayed if there is more than 1 item + 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) { - const prevPageItem = document.createElement('li'); - prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - prevPageItem.innerHTML = ` - - - Previous - - `; - prevPageItem.querySelector('a').addEventListener('click', (event) => { - event.preventDefault(); - loadPageFunction(currentPage - 1); - }); - paginationButtons.appendChild(prevPageItem); + if (hasPrevious) { + const prevPageItem = document.createElement('li'); + prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; + prevPageItem.innerHTML = ` + + + Previous + + `; + prevPageItem.querySelector('a').addEventListener('click', (event) => { + event.preventDefault(); + this.loadTable(currentPage - 1); + }); + paginationButtons.appendChild(prevPageItem); + } + + // Add first page and ellipsis if necessary + if (currentPage > 2) { + paginationButtons.appendChild(this.createPageItem(1, parentTableSelector, currentPage)); + if (currentPage > 3) { + const ellipsis = document.createElement('li'); + ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; + ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages'); + ellipsis.innerHTML = ''; + paginationButtons.appendChild(ellipsis); + } + } + + // Add pages around the current page + for (let i = Math.max(1, currentPage - 1); i <= Math.min(numPages, currentPage + 1); i++) { + paginationButtons.appendChild(this.createPageItem(i, parentTableSelector, currentPage)); + } + + // Add last page and ellipsis if necessary + if (currentPage < numPages - 1) { + if (currentPage < numPages - 2) { + const ellipsis = document.createElement('li'); + ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; + ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages'); + ellipsis.innerHTML = ''; + paginationButtons.appendChild(ellipsis); + } + paginationButtons.appendChild(this.createPageItem(numPages, parentTableSelector, currentPage)); + } + + if (hasNext) { + const nextPageItem = document.createElement('li'); + nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; + nextPageItem.innerHTML = ` + + Next + + + `; + nextPageItem.querySelector('a').addEventListener('click', (event) => { + event.preventDefault(); + this.loadTable(currentPage + 1); + }); + paginationButtons.appendChild(nextPageItem); + } } + /** + * A helper that toggles content/ no content/ no search results + * + */ + updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => { + const { unfiltered_total, total } = data; + if (unfiltered_total) { + if (total) { + showElement(dataWrapper); + hideElement(noSearchResultsWrapper); + hideElement(noDataWrapper); + } else { + hideElement(dataWrapper); + showElement(noSearchResultsWrapper); + hideElement(noDataWrapper); + } + } else { + hideElement(dataWrapper); + hideElement(noSearchResultsWrapper); + showElement(noDataWrapper); + } + }; + // Helper function to create a page item - function createPageItem(page) { + createPageItem(page, parentTableSelector, currentPage) { const pageItem = document.createElement('li'); pageItem.className = 'usa-pagination__item usa-pagination__page-no'; pageItem.innerHTML = ` - ${page} + ${page} `; if (page === currentPage) { pageItem.querySelector('a').classList.add('usa-current'); @@ -1054,134 +1172,212 @@ function updatePagination(itemName, paginationSelector, counterSelector, linkAnc } pageItem.querySelector('a').addEventListener('click', (event) => { event.preventDefault(); - loadPageFunction(page); + this.loadTable(page); }); return pageItem; } - // Add first page and ellipsis if necessary - if (currentPage > 2) { - paginationButtons.appendChild(createPageItem(1)); - if (currentPage > 3) { - const ellipsis = document.createElement('li'); - ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; - ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages'); - ellipsis.innerHTML = ''; - paginationButtons.appendChild(ellipsis); - } + /** + * A helper that resets sortable table headers + * + */ + unsetHeader = (header) => { + header.removeAttribute('aria-sort'); + let headerName = header.innerText; + const headerLabel = `${headerName}, sortable column, currently unsorted"`; + const headerButtonLabel = `Click to sort by ascending order.`; + header.setAttribute("aria-label", headerLabel); + header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel); + }; + + // Abstract method (to be implemented in the child class) + loadTable(page, sortBy, order) { + throw new Error('loadData() must be implemented in a subclass'); } - // Add pages around the current page - for (let i = Math.max(1, currentPage - 1); i <= Math.min(numPages, currentPage + 1); i++) { - paginationButtons.appendChild(createPageItem(i)); - } - - // Add last page and ellipsis if necessary - if (currentPage < numPages - 1) { - if (currentPage < numPages - 2) { - const ellipsis = document.createElement('li'); - ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; - ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages'); - ellipsis.innerHTML = ''; - paginationButtons.appendChild(ellipsis); - } - paginationButtons.appendChild(createPageItem(numPages)); - } - - if (hasNext) { - const nextPageItem = document.createElement('li'); - nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - nextPageItem.innerHTML = ` - - Next - - - `; - nextPageItem.querySelector('a').addEventListener('click', (event) => { - event.preventDefault(); - loadPageFunction(currentPage + 1); + // Add event listeners to table headers for sorting + initializeTableHeaders() { + this.tableHeaders.forEach(header => { + header.addEventListener('click', () => { + const sortBy = header.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 === this.currentSortBy) { + order = this.currentOrder === 'asc' ? 'desc' : 'asc'; + } + // load the results with the updated sort + this.loadTable(1, sortBy, order); + }); + }); + } + + 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(); + } }); - paginationButtons.appendChild(nextPageItem); } } -/** - * A helper that toggles content/ no content/ no search results - * -*/ -const updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => { - const { unfiltered_total, total } = data; - if (unfiltered_total) { - if (total) { - showElement(dataWrapper); - hideElement(noSearchResultsWrapper); - hideElement(noDataWrapper); - } else { - hideElement(dataWrapper); - showElement(noSearchResultsWrapper); - hideElement(noDataWrapper); - } - } else { - hideElement(dataWrapper); - hideElement(noSearchResultsWrapper); - showElement(noDataWrapper); +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'); } -}; - -/** - * A helper that resets sortable table headers - * -*/ -const unsetHeader = (header) => { - header.removeAttribute('aria-sort'); - let headerName = header.innerText; - const headerLabel = `${headerName}, sortable column, currently unsorted"`; - const headerButtonLabel = `Click to sort by ascending order.`; - header.setAttribute("aria-label", headerLabel); - header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel); -}; - -/** - * An IIFE that listens for DOM Content to be loaded, then executes. This function - * initializes the domains list and associated functionality on the home page of the app. - * - */ -document.addEventListener('DOMContentLoaded', function() { - const domainsWrapper = document.querySelector('.domains__table-wrapper'); - - if (domainsWrapper) { - let currentSortBy = 'id'; - let currentOrder = 'asc'; - const noDomainsWrapper = document.querySelector('.domains__no-data'); - const noSearchResultsWrapper = document.querySelector('.domains__no-search-results'); - let scrollToTable = false; - let currentStatus = []; - let currentSearchTerm = ''; - const domainsSearchInput = document.getElementById('domains__search-field'); - const domainsSearchSubmit = document.getElementById('domains__search-field-submit'); - const tableHeaders = document.querySelectorAll('.domains__table th[data-sortable]'); - const tableAnnouncementRegion = document.querySelector('.domains__table-wrapper .usa-table__announcement-region'); - const resetSearchButton = document.querySelector('.domains__reset-search'); - 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; - - /** + /** * Loads rows in the domains list, as well as updates pagination around the domains list * based on the supplied attributes. * @param {*} page - the page number of the results (starts with 1) * @param {*} sortBy - the sort column option * @param {*} order - the sort order {asc, desc} * @param {*} scroll - control for the scrollToElement functionality + * @param {*} status - control for the status filter * @param {*} searchTerm - the search term * @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 let baseUrl = document.getElementById("get_domains_json_url"); if (!baseUrl) { @@ -1194,10 +1390,19 @@ document.addEventListener('DOMContentLoaded', function() { } // 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) - url += `&portfolio=${portfolio}` + searchParams.append("portfolio", portfolio) + let url = `${baseUrlValue}?${searchParams.toString()}` fetch(url) .then(response => response.json()) .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 - 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 const domainList = document.querySelector('.domains__table tbody'); @@ -1225,7 +1430,7 @@ document.addEventListener('DOMContentLoaded', function() { let markupForSuborganizationRow = ''; - if (portfolioValue) { + if (this.portfolioValue) { markupForSuborganizationRow = ` ${suborganization} @@ -1271,181 +1476,438 @@ document.addEventListener('DOMContentLoaded', function() { // Do not scroll on first page load if (scroll) ScrollToElement('class', 'domains'); - scrollToTable = true; + this.scrollToTable = true; // update pagination - updatePagination( + this.updatePagination( 'domain', '#domains-pagination', '#domains-pagination .usa-pagination__counter', '#domains', - loadDomains, data.page, data.num_pages, data.has_previous, data.has_next, data.total, - currentSearchTerm ); - currentSortBy = sortBy; - currentOrder = order; - currentSearchTerm = searchTerm; + this.currentSortBy = sortBy; + this.currentOrder = order; + this.currentSearchTerm = searchTerm; }) .catch(error => console.error('Error fetching domains:', error)); + } +} + + +class DomainRequestsTable extends LoadTableBase { + + constructor() { + 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'); + } + /** + * Loads rows in the domains list, as well as updates pagination around the domains list + * based on the supplied attributes. + * @param {*} page - the page number of the results (starts with 1) + * @param {*} sortBy - the sort column option + * @param {*} order - the sort order {asc, desc} + * @param {*} scroll - control for the scrollToElement functionality + * @param {*} status - control for the status filter + * @param {*} searchTerm - the search term + * @param {*} portfolio - the portfolio id + */ + loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) { + let baseUrl = document.getElementById("get_domain_requests_json_url"); + if (!baseUrl) { + return; } - // 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); - }); - }); + let baseUrlValue = baseUrl.innerHTML; + if (!baseUrlValue) { + return; + } - 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); + // add searchParams + let searchParams = new URLSearchParams( + { + "page": page, + "sort_by": sortBy, + "order": order, + "status": status, + "search_term": searchTerm } - loadDomains(1, 'id', 'asc'); - resetHeaders(); - }); + ); + if (portfolio) + searchParams.append("portfolio", portfolio) - if (statusToggle) { - statusToggle.addEventListener('click', function() { - toggleCaret(statusToggle); - }); - } + let url = `${baseUrlValue}?${searchParams.toString()}` + fetch(url) + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error('Error in AJAX call: ' + data.error); + return; + } - // 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); + // handle the display of proper messaging in the event that no requests exist in the list or search returns no results + 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 + const tbody = document.querySelector('.domain-requests__table tbody'); + tbody.innerHTML = ''; + + // Unload modals will re-inject the DOM with the initial placeholders to allow for .on() in regular use cases + // We do NOT want that as it will cause multiple placeholders and therefore multiple inits on delete, + // which will cause bad delete requests to be sent. + const preExistingModalPlaceholders = document.querySelectorAll('[data-placeholder-for^="toggle-delete-domain-alert"]'); + preExistingModalPlaceholders.forEach(element => { + element.remove(); + }); + + // remove any existing modal elements from the DOM so they can be properly re-initialized + // after the DOM content changes and there are new delete modal buttons added + unloadModals(); + + let needsDeleteColumn = false; + + needsDeleteColumn = data.domain_requests.some(request => request.is_deletable); + + // Remove existing delete th and td if they exist + let existingDeleteTh = document.querySelector('.delete-header'); + if (!needsDeleteColumn) { + if (existingDeleteTh) + existingDeleteTh.remove(); } else { - const index = currentStatus.indexOf(checkboxValue); - if (index > -1) { - currentStatus.splice(index, 1); + if (!existingDeleteTh) { + const delheader = document.createElement('th'); + delheader.setAttribute('scope', 'col'); + delheader.setAttribute('role', 'columnheader'); + delheader.setAttribute('class', 'delete-header'); + delheader.innerHTML = ` + Delete Action`; + let tableHeaderRow = document.querySelector('.domain-requests__table thead tr'); + tableHeaderRow.appendChild(delheader); } } - // Manage visibility of reset filters button - if (currentStatus.length == 0) { - hideElement(resetFiltersButton); - } else { - showElement(resetFiltersButton); - } + data.domain_requests.forEach(request => { + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + const domainName = request.requested_domain ? request.requested_domain : `New domain request
(${utcDateString(request.created_at)})`; + const actionUrl = request.action_url; + const actionLabel = request.action_label; + const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `Not submitted`; + + // The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page) + // Even if the request is not deletable, we may need these empty strings for the td if the deletable column is displayed + let modalTrigger = ''; - // Disable the auto scroll - scrollToTable = false; + let markupCreatorRow = ''; - // Call loadDomains with updated status - loadDomains(1, 'id', 'asc'); - resetHeaders(); - updateStatusIndicator(); - }); - }); + if (this.portfolioValue) { + markupCreatorRow = ` + + ${request.creator ? request.creator : ''} + + ` + } - // Reset UI and accessibility - function resetHeaders() { - tableHeaders.forEach(header => { - // Unset sort UI in headers - unsetHeader(header); - }); - // Reset the announcement region - tableAnnouncementRegion.innerHTML = ''; - } + // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages + if (request.is_deletable) { + let modalHeading = ''; + let modalDescription = ''; - function resetSearch() { - domainsSearchInput.value = ''; - currentSearchTerm = ''; - hideElement(resetSearchButton); - loadDomains(1, 'id', 'asc'); - resetHeaders(); - } + if (request.requested_domain) { + modalHeading = `Are you sure you want to delete ${request.requested_domain}?`; + modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; + } else { + if (request.created_at) { + modalHeading = 'Are you sure you want to delete this domain request?'; + modalDescription = `This will remove the domain request (created ${utcDateString(request.created_at)}) from the .gov registrar. This action cannot be undone`; + } else { + modalHeading = 'Are you sure you want to delete New domain request?'; + modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; + } + } - if (resetSearchButton) { - resetSearchButton.addEventListener('click', function() { - resetSearch(); - }); - } + modalTrigger = ` + + Delete ${domainName} + ` - function resetFilters() { - currentStatus = []; - statusCheckboxes.forEach(checkbox => { - checkbox.checked = false; - }); - hideElement(resetFiltersButton); + const modalSubmit = ` + + ` - // Disable the auto scroll - scrollToTable = false; + const modal = document.createElement('div'); + modal.setAttribute('class', 'usa-modal'); + modal.setAttribute('id', `toggle-delete-domain-alert-${request.id}`); + modal.setAttribute('aria-labelledby', 'Are you sure you want to continue?'); + modal.setAttribute('aria-describedby', 'Domain will be removed'); + modal.setAttribute('data-force-action', ''); - loadDomains(1, 'id', 'asc'); - resetHeaders(); - updateStatusIndicator(); - // No need to toggle close the filters. The focus shift will trigger that for us. - } + modal.innerHTML = ` +
+
+ +
+ +
+ +
+ +
+ ` - if (resetFiltersButton) { - resetFiltersButton.addEventListener('click', function() { - resetFilters(); - }); - } + this.tableWrapper.appendChild(modal); - 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(); - } + // 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 (this.portfolioValue) { + modalTrigger = ` + + Delete ${domainName} + - 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"]'); + const row = document.createElement('tr'); + row.innerHTML = ` + + ${domainName} + + + ${submissionDate} + + ${markupCreatorRow} + + ${request.status} + + + + + ${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'} + + + ${needsDeleteColumn ? ''+modalTrigger+'' : ''} + `; + tbody.appendChild(row); + }); + + // initialize modals immediately after the DOM content is updated + initializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + let pk = submitButton.getAttribute('data-pk'); + // Close the modal to remove the USWDS UI local classes + closeButton.click(); + // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page + let pageToDisplay = data.page; + if (data.total == 1 && data.unfiltered_total > 1) { + pageToDisplay--; + } + this.deleteDomainRequest(pk, pageToDisplay); + }); + }); + + // Do not scroll on first page load + if (scroll) + ScrollToElement('class', 'domain-requests'); + this.scrollToTable = true; + + // update the pagination after the domain requests list is updated + this.updatePagination( + 'domain request', + '#domain-requests-pagination', + '#domain-requests-pagination .usa-pagination__counter', + '#domain-requests', + data.page, + data.num_pages, + data.has_previous, + data.has_next, + data.total, + ); + this.currentSortBy = sortBy; + this.currentOrder = order; + this.currentSearchTerm = searchTerm; + }) + .catch(error => console.error('Error fetching domain requests:', error)); + } + + /** + * 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 + */ + deleteDomainRequest(domainRequestPk, pageToDisplay) { + // Use to debug uswds modal issues + //console.log('deleteDomainRequest') - if (accordionThatIsOpen && !accordion.contains(event.target)) { - closeFilters(); + // 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 + this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentSearchTerm); + }) + .catch(error => console.error('Error fetching domain requests:', error)); + } +} + + +/** + * An IIFE that listens for DOM Content to be loaded, then executes. This function + * 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); + } + } +}); + +/** + * An IIFE that listens for DOM Content to be loaded, then executes. This function + * initializes the domain requests list and associated functionality on the home page of the app. + * + */ +document.addEventListener('DOMContentLoaded', function() { + const domainRequestsSectionWrapper = document.querySelector('.domain-requests'); + if (domainRequestsSectionWrapper) { + const domainRequestsTable = new DomainRequestsTable(); + if (domainRequestsTable.tableWrapper) { + domainRequestsTable.loadTable(1); + } + } + + document.addEventListener('focusin', function(event) { + closeOpenAccordions(event); + }); + + document.addEventListener('click', function(event) { + closeOpenAccordions(event); + }); + + function closeMoreActionMenu(accordionThatIsOpen) { + if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") { + accordionThatIsOpen.click(); + } + } + + function closeOpenAccordions(event) { + const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]'); + openAccordions.forEach((openAccordionButton) => { + // Find the corresponding accordion + const accordion = openAccordionButton.closest('.usa-accordion--more-actions'); + if (accordion && !accordion.contains(event.target)) { + // Close the accordion if the click is outside + closeMoreActionMenu(openAccordionButton); } }); - - // Initial load - loadDomains(1); } }); @@ -1463,458 +1925,6 @@ const utcDateString = (dateString) => { return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} ${ampm} UTC`; }; -/** - * An IIFE that listens for DOM Content to be loaded, then executes. This function - * initializes the domain requests list and associated functionality on the home page of the app. - * - */ -document.addEventListener('DOMContentLoaded', function() { - 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. - * @param {*} page - the page number of the results (starts with 1) - * @param {*} sortBy - the sort column option - * @param {*} order - the sort order {asc, desc} - * @param {*} scroll - control for the scrollToElement functionality - * @param {*} searchTerm - the search term - */ - function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm, portfolio = portfolioValue) { - // fetch json of page of domain requests, given params - let baseUrl = document.getElementById("get_domain_requests_json_url"); - if (!baseUrl) { - return; - } - - let baseUrlValue = baseUrl.innerHTML; - if (!baseUrlValue) { - return; - } - - // fetch json of page of requests, given params - let url = `${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}` - if (portfolio) - url += `&portfolio=${portfolio}` - - fetch(url) - .then(response => response.json()) - .then(data => { - if (data.error) { - console.error('Error in AJAX call: ' + data.error); - return; - } - - // 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); - - // identify the DOM element where the domain request list will be inserted into the DOM - const tbody = document.querySelector('.domain-requests__table tbody'); - tbody.innerHTML = ''; - - // Unload modals will re-inject the DOM with the initial placeholders to allow for .on() in regular use cases - // We do NOT want that as it will cause multiple placeholders and therefore multiple inits on delete, - // which will cause bad delete requests to be sent. - const preExistingModalPlaceholders = document.querySelectorAll('[data-placeholder-for^="toggle-delete-domain-alert"]'); - preExistingModalPlaceholders.forEach(element => { - element.remove(); - }); - - // remove any existing modal elements from the DOM so they can be properly re-initialized - // after the DOM content changes and there are new delete modal buttons added - unloadModals(); - - let needsDeleteColumn = false; - - needsDeleteColumn = data.domain_requests.some(request => request.is_deletable); - - // Remove existing delete th and td if they exist - let existingDeleteTh = document.querySelector('.delete-header'); - if (!needsDeleteColumn) { - if (existingDeleteTh) - existingDeleteTh.remove(); - } else { - if (!existingDeleteTh) { - const delheader = document.createElement('th'); - delheader.setAttribute('scope', 'col'); - delheader.setAttribute('role', 'columnheader'); - delheader.setAttribute('class', 'delete-header'); - delheader.innerHTML = ` - Delete Action`; - let tableHeaderRow = document.querySelector('.domain-requests__table thead tr'); - tableHeaderRow.appendChild(delheader); - } - } - - data.domain_requests.forEach(request => { - const options = { year: 'numeric', month: 'short', day: 'numeric' }; - const domainName = request.requested_domain ? request.requested_domain : `New domain request
(${utcDateString(request.created_at)})`; - const actionUrl = request.action_url; - const actionLabel = request.action_label; - const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `Not submitted`; - - // The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page) - // Even if the request is not deletable, we may need these empty strings for the td if the deletable column is displayed - let modalTrigger = ''; - - let markupCreatorRow = ''; - - if (portfolioValue) { - markupCreatorRow = ` - - ${request.creator ? request.creator : ''} - - ` - } - - // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages - if (request.is_deletable) { - let modalHeading = ''; - let modalDescription = ''; - - if (request.requested_domain) { - modalHeading = `Are you sure you want to delete ${request.requested_domain}?`; - modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; - } else { - if (request.created_at) { - modalHeading = 'Are you sure you want to delete this domain request?'; - modalDescription = `This will remove the domain request (created ${utcDateString(request.created_at)}) from the .gov registrar. This action cannot be undone`; - } else { - modalHeading = 'Are you sure you want to delete New domain request?'; - modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; - } - } - - modalTrigger = ` - - Delete ${domainName} - ` - - const modalSubmit = ` - - ` - - const modal = document.createElement('div'); - modal.setAttribute('class', 'usa-modal'); - modal.setAttribute('id', `toggle-delete-domain-alert-${request.id}`); - modal.setAttribute('aria-labelledby', 'Are you sure you want to continue?'); - modal.setAttribute('aria-describedby', 'Domain will be removed'); - modal.setAttribute('data-force-action', ''); - - modal.innerHTML = ` -
-
- -
- -
- -
- -
- ` - - domainRequestsSectionWrapper.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 - if (portfolioValue) { - modalTrigger = ` - - Delete ${domainName} - - -
-
- -
- -
- ` - } - } - - - const row = document.createElement('tr'); - row.innerHTML = ` - - ${domainName} - - - ${submissionDate} - - ${markupCreatorRow} - - ${request.status} - - - - - ${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'} - - - ${needsDeleteColumn ? ''+modalTrigger+'' : ''} - `; - tbody.appendChild(row); - }); - - // initialize modals immediately after the DOM content is updated - initializeModals(); - - // Now the DOM and modals are ready, add listeners to the submit buttons - const modals = document.querySelectorAll('.usa-modal__content'); - - modals.forEach(modal => { - const submitButton = modal.querySelector('.usa-modal__submit'); - const closeButton = modal.querySelector('.usa-modal__close'); - submitButton.addEventListener('click', function() { - pk = submitButton.getAttribute('data-pk'); - // Close the modal to remove the USWDS UI local classes - closeButton.click(); - // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page - let pageToDisplay = data.page; - if (data.total == 1 && data.unfiltered_total > 1) { - pageToDisplay--; - } - deleteDomainRequest(pk, pageToDisplay); - }); - }); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', 'domain-requests'); - scrollToTable = true; - - // update the pagination after the domain requests list is updated - updatePagination( - 'domain request', - '#domain-requests-pagination', - '#domain-requests-pagination .usa-pagination__counter', - '#domain-requests', - loadDomainRequests, - data.page, - data.num_pages, - data.has_previous, - data.has_next, - data.total, - currentSearchTerm - ); - currentSortBy = sortBy; - currentOrder = order; - currentSearchTerm = searchTerm; - }) - .catch(error => console.error('Error fetching domain requests:', error)); - } - - // Add event listeners to table headers for sorting - 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'; - } - loadDomainRequests(1, sortBy, order); - }); - }); - - domainRequestsSearchSubmit.addEventListener('click', function(e) { - e.preventDefault(); - currentSearchTerm = domainRequestsSearchInput.value; - // If the search is blank, we match the resetSearch functionality - if (currentSearchTerm) { - showElement(resetSearchButton); - } else { - hideElement(resetSearchButton); - } - loadDomainRequests(1, 'id', 'asc'); - resetHeaders(); - }); - - // Reset UI and accessibility - function resetHeaders() { - tableHeaders.forEach(header => { - // unset sort UI in headers - unsetHeader(header); - }); - // Reset the announcement region - tableAnnouncementRegion.innerHTML = ''; - } - - function resetSearch() { - domainRequestsSearchInput.value = ''; - currentSearchTerm = ''; - hideElement(resetSearchButton); - loadDomainRequests(1, 'id', 'asc'); - resetHeaders(); - } - - if (resetSearchButton) { - resetSearchButton.addEventListener('click', function() { - resetSearch(); - }); - } - - function closeMoreActionMenu(accordionThatIsOpen) { - if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") { - accordionThatIsOpen.click(); - } - } - - document.addEventListener('focusin', function(event) { - closeOpenAccordions(event); - }); - - document.addEventListener('click', function(event) { - closeOpenAccordions(event); - }); - - function closeOpenAccordions(event) { - const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]'); - openAccordions.forEach((openAccordionButton) => { - // Find the corresponding accordion - const accordion = openAccordionButton.closest('.usa-accordion--more-actions'); - if (accordion && !accordion.contains(event.target)) { - // Close the accordion if the click is outside - closeMoreActionMenu(openAccordionButton); - } - }); - } - - // Initial load - loadDomainRequests(1); - } -}); - /** * An IIFE that displays confirmation modal on the user profile page diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index 0c123948e..375e0229c 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -23,13 +23,21 @@ Reset + {% if portfolio %} + + {% else %} + {% endif %} + +
+

Status

+
+ Select to apply status filter +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + {% endif %}