diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index adcc21d2a..c3a0a5319 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -86,6 +86,132 @@ function makeVisible(el) { el.style.visibility = "visible"; } +/** + * Creates and adds a modal dialog to the DOM with customizable attributes and content. + * + * @param {string} id - A unique identifier for the modal, appended to the action for uniqueness. + * @param {string} ariaLabelledby - The ID of the element that labels the modal, for accessibility. + * @param {string} ariaDescribedby - The ID of the element that describes the modal, for accessibility. + * @param {string} modalHeading - The heading text displayed at the top of the modal. + * @param {string} modalDescription - The main descriptive text displayed within the modal. + * @param {string} modalSubmit - The HTML content for the submit button, allowing customization. + * @param {HTMLElement} wrapper_element - Optional. The element to which the modal is appended. If not provided, defaults to `document.body`. + * @param {boolean} forceAction - Optional. If true, adds a `data-force-action` attribute to the modal for additional control. + * + * The modal includes a heading, description, submit button, and a cancel button, along with a close button. + * The `data-close-modal` attribute is added to cancel and close buttons to enable closing functionality. + */ +function addModal(id, ariaLabelledby, ariaDescribedby, modalHeading, modalDescription, modalSubmit, wrapper_element, forceAction) { + + const modal = document.createElement('div'); + modal.setAttribute('class', 'usa-modal'); + modal.setAttribute('id', id); + modal.setAttribute('aria-labelledby', ariaLabelledby); + modal.setAttribute('aria-describedby', ariaDescribedby); + if (forceAction) + modal.setAttribute('data-force-action', ''); + + modal.innerHTML = ` +
+
+

+ ${modalHeading} +

+
+

+ ${modalDescription} +

+
+ +
+ +
+ ` + if (wrapper_element) { + wrapper_element.appendChild(modal); + } else { + document.body.appendChild(modal); + } +} + +/** + * Helper function that creates a dynamic accordion navigation + * @param {string} action - The action type or identifier used to create a unique DOM IDs. + * @param {string} unique_id - An ID that when combined with action makes a unique identifier + * @param {string} modal_button_text - The action button's text + * @param {string} screen_reader_text - A screen reader helper + */ +function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text) { + + const generateModalButton = (mobileOnly = false) => ` + + ${mobileOnly ? `` : ''} + ${modal_button_text} + ${screen_reader_text} + + `; + + // Main kebab structure + const kebab = ` + ${generateModalButton(true)} + +
+
+ +
+ +
+ `; + + return kebab; +} + + /** * Toggles expand_more / expand_more svgs in buttons or anchors * @param {Element} element - DOM element @@ -104,9 +230,9 @@ function toggleCaret(element) { } /** - * Helper function that scrolls to an element + * Helper function that scrolls to an element, identified by a class or an id. * @param {string} attributeName - The string "class" or "id" - * @param {string} attributeValue - The class or id name + * @param {string} attributeValue - The class or id used name to identify the element */ function ScrollToElement(attributeName, attributeValue) { let targetEl = null; @@ -1022,46 +1148,54 @@ function initializeTooltips() { * Initialize USWDS modals by calling on method. Requires that uswds-edited.js be loaded * before get-gov.js. uswds-edited.js adds the modal module to the window to be accessible * directly in get-gov.js. - * initializeModals adds modal-related DOM elements, based on other DOM elements existing in + * load Modals adds modal-related DOM elements, based on other DOM elements existing in * the page. It needs to be called only once for any particular DOM element; otherwise, it * will initialize improperly. Therefore, if DOM elements change dynamically and include - * DOM elements with modal classes, unloadModals needs to be called before initializeModals. + * DOM elements with modal classes, uswdsUnloadModals needs to be called before loadModals. * */ -function initializeModals() { - window.modal.on(); +function uswdsInitializeModals() { + window.modal.on(); + } /** * Unload existing USWDS modals by calling off method. Requires that uswds-edited.js be * loaded before get-gov.js. uswds-edited.js adds the modal module to the window to be * accessible directly in get-gov.js. - * See note above with regards to calling this method relative to initializeModals. + * See note above with regards to calling this method relative to loadModals. * */ -function unloadModals() { +function uswdsUnloadModals() { window.modal.off(); } -class LoadTableBase { - constructor(sectionSelector) { - this.tableWrapper = document.getElementById(`${sectionSelector}__table-wrapper`); - this.tableHeaders = document.querySelectorAll(`#${sectionSelector} th[data-sortable]`); +/** + * Base table class which handles search, retrieval, rendering and interaction with results. + * Classes can extend the basic behavior of this class to customize display and interaction. + * NOTE: PLEASE notice that whatever itemName is coming in will have an "s" added to it (ie domain -> domains) + */ +class BaseTable { + constructor(itemName) { + this.itemName = itemName; + this.sectionSelector = itemName + 's'; + this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`); + this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`); this.currentSortBy = 'id'; this.currentOrder = 'asc'; this.currentStatus = []; this.currentSearchTerm = ''; this.scrollToTable = false; - this.searchInput = document.getElementById(`${sectionSelector}__search-field`); - this.searchSubmit = document.getElementById(`${sectionSelector}__search-field-submit`); - this.tableAnnouncementRegion = document.getElementById(`${sectionSelector}__usa-table__announcement-region`); - this.resetSearchButton = document.getElementById(`${sectionSelector}__reset-search`); - this.resetFiltersButton = document.getElementById(`${sectionSelector}__reset-filters`); - this.statusCheckboxes = document.querySelectorAll(`.${sectionSelector} input[name="filter-status"]`); - this.statusIndicator = document.getElementById(`${sectionSelector}__filter-indicator`); - this.statusToggle = document.getElementById(`${sectionSelector}__usa-button--filter`); - this.noTableWrapper = document.getElementById(`${sectionSelector}__no-data`); - this.noSearchResultsWrapper = document.getElementById(`${sectionSelector}__no-search-results`); + this.searchInput = document.getElementById(`${this.sectionSelector}__search-field`); + this.searchSubmit = document.getElementById(`${this.sectionSelector}__search-field-submit`); + this.tableAnnouncementRegion = document.getElementById(`${this.sectionSelector}__usa-table__announcement-region`); + this.resetSearchButton = document.getElementById(`${this.sectionSelector}__reset-search`); + this.resetFiltersButton = document.getElementById(`${this.sectionSelector}__reset-filters`); + this.statusCheckboxes = document.querySelectorAll(`.${this.sectionSelector} input[name="filter-status"]`); + this.statusIndicator = document.getElementById(`${this.sectionSelector}__filter-indicator`); + this.statusToggle = document.getElementById(`${this.sectionSelector}__usa-button--filter`); + this.noTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`); + this.noSearchResultsWrapper = document.getElementById(`${this.sectionSelector}__no-search-results`); this.portfolioElement = document.getElementById('portfolio-js-value'); this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null; this.initializeTableHeaders(); @@ -1074,31 +1208,24 @@ class LoadTableBase { } /** - * 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} 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} total - The total number of items. - */ + * Generalized function to update pagination for a list. + * @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} total - The total number of items. + */ updatePagination( - itemName, - paginationSelector, - counterSelector, - parentTableSelector, currentPage, numPages, hasPrevious, hasNext, - totalItems, + totalItems ) { - const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`); - const counterSelectorEl = document.querySelector(counterSelector); - const paginationSelectorEl = document.querySelector(paginationSelector); + const paginationButtons = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__list`); + const counterSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__counter`); + const paginationSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination`); + const parentTableSelector = `#${this.sectionSelector}`; counterSelectorEl.innerHTML = ''; paginationButtons.innerHTML = ''; @@ -1108,12 +1235,30 @@ class LoadTableBase { // Counter should only be displayed if there is more than 1 item paginationSelectorEl.classList.toggle('display-none', totalItems < 1); - counterSelectorEl.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; + counterSelectorEl.innerHTML = `${totalItems} ${this.itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; + + // Helper function to create a pagination item, such as a + const createPaginationItem = (page) => { + const paginationItem = document.createElement('li'); + paginationItem.classList.add('usa-pagination__item', 'usa-pagination__page-no'); + paginationItem.innerHTML = ` + ${page} + `; + if (page === currentPage) { + paginationItem.querySelector('a').classList.add('usa-current'); + paginationItem.querySelector('a').setAttribute('aria-current', 'page'); + } + paginationItem.querySelector('a').addEventListener('click', (event) => { + event.preventDefault(); + this.loadTable(page); + }); + return paginationItem; + }; if (hasPrevious) { - const prevPageItem = document.createElement('li'); - prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - prevPageItem.innerHTML = ` + const prevPaginationItem = document.createElement('li'); + prevPaginationItem.className = 'usa-pagination__item usa-pagination__arrow'; + prevPaginationItem.innerHTML = ` Previous `; - prevPageItem.querySelector('a').addEventListener('click', (event) => { + prevPaginationItem.querySelector('a').addEventListener('click', (event) => { event.preventDefault(); this.loadTable(currentPage - 1); }); - paginationButtons.appendChild(prevPageItem); + paginationButtons.appendChild(prevPaginationItem); } // Add first page and ellipsis if necessary if (currentPage > 2) { - paginationButtons.appendChild(this.createPageItem(1, parentTableSelector, currentPage)); + paginationButtons.appendChild(createPaginationItem(1)); if (currentPage > 3) { const ellipsis = document.createElement('li'); ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; @@ -1142,7 +1287,7 @@ class LoadTableBase { // 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)); + paginationButtons.appendChild(createPaginationItem(i)); } // Add last page and ellipsis if necessary @@ -1154,13 +1299,13 @@ class LoadTableBase { ellipsis.innerHTML = ''; paginationButtons.appendChild(ellipsis); } - paginationButtons.appendChild(this.createPageItem(numPages, parentTableSelector, currentPage)); + paginationButtons.appendChild(createPaginationItem(numPages)); } if (hasNext) { - const nextPageItem = document.createElement('li'); - nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - nextPageItem.innerHTML = ` + const nextPaginationItem = document.createElement('li'); + nextPaginationItem.className = 'usa-pagination__item usa-pagination__arrow'; + nextPaginationItem.innerHTML = ` Next `; - nextPageItem.querySelector('a').addEventListener('click', (event) => { + nextPaginationItem.querySelector('a').addEventListener('click', (event) => { event.preventDefault(); this.loadTable(currentPage + 1); }); - paginationButtons.appendChild(nextPageItem); + paginationButtons.appendChild(nextPaginationItem); } } /** - * A helper that toggles content/ no content/ no search results - * - */ + * A helper that toggles content/ no content/ no search results based on results in data. + * @param {Object} data - Data representing current page of results data. + * @param {HTMLElement} dataWrapper - The DOM element to show if there are results on the current page. + * @param {HTMLElement} noDataWrapper - The DOM element to show if there are no results period. + * @param {HTMLElement} noSearchResultsWrapper - The DOM element to show if there are no results in the current filtered search. + */ updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => { const { unfiltered_total, total } = data; if (unfiltered_total) { @@ -1199,24 +1347,6 @@ class LoadTableBase { } }; - // 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 = ` - ${page} - `; - 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 * @@ -1230,11 +1360,186 @@ class LoadTableBase { 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'); + + /** + * Generates search params for filtering and sorting + * @param {number} page - The current page number for pagination (starting with 1) + * @param {*} sortBy - The sort column option + * @param {*} order - The order of sorting {asc, desc} + * @param {string} searchTerm - The search term used to filter results for a specific keyword + * @param {*} status - The status filter applied {ready, dns_needed, etc} + * @param {string} portfolio - The portfolio id + */ + getSearchParams(page, sortBy, order, searchTerm, status, portfolio) { + let searchParams = new URLSearchParams( + { + "page": page, + "sort_by": sortBy, + "order": order, + "search_term": searchTerm, + } + ); + + let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null; + let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null; + let memberOnly = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-only') : null; + + if (portfolio) + searchParams.append("portfolio", portfolio); + if (emailValue) + searchParams.append("email", emailValue); + if (memberIdValue) + searchParams.append("member_id", memberIdValue); + if (memberOnly) + searchParams.append("member_only", memberOnly); + if (status) + searchParams.append("status", status); + return searchParams; } + /** + * Gets the base URL of API requests + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + */ + getBaseUrl() { + throw new Error('getBaseUrl must be defined'); + } + + /** + * Calls "uswdsUnloadModals" to remove any existing modal element to make sure theres no unintended consequences + * from leftover event listeners + can be properly re-initialized + */ + unloadModals(){} + + /** + * Loads modals + sets up event listeners for the modal submit actions + * "Activates" the modals after the DOM updates + * Utilizes "uswdsInitializeModals" + * Adds click event listeners to each modal's submit button so we can handle a user's actions + * + * When the submit button is clicked: + * - Triggers the close button to reset modal classes + * - Determines if the page needs refreshing if the last item is deleted + * @param {number} page - The current page number for pagination + * @param {number} total - The total # of items on the current page + * @param {number} unfiltered_total - The total # of items across all pages + */ + loadModals(page, total, unfiltered_total) {} + + /** + * Allows us to customize the table display based on specific conditions and a user's permissions + * Dynamically manages the visibility set up of columns, adding/removing headers + * (ie if a domain request is deleteable, we include the kebab column or if a user has edit permissions + * for a member, they will also see the kebab column) + * @param {Object} dataObjects - Data which contains info on domain requests or a user's permission + * Currently returns a dictionary of either: + * - "needsAdditionalColumn": If a new column should be displayed + * - "UserPortfolioPermissionChoices": A user's portfolio permission choices + */ + customizeTable(dataObjects){ return {}; } + + /** + * Retrieves specific data objects + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + * Returns either: data.members, data.domains or data.domain_requests + * @param {Object} data - The full data set from which a subset of objects is extracted. + */ + getDataObjects(data) { + throw new Error('getDataObjects must be defined'); + } + + /** + * Creates + appends a row to a tbody element + * Tailored structure set up for each data object (domain, domain_request, member, etc) + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + * Returns either: data.members, data.domains or data.domain_requests + * @param {Object} dataObject - The data used to populate the row content + * @param {HTMLElement} tbody - The table body to which the new row is appended to + * @param {Object} customTableOptions - Additional options for customizing row appearance (ie needsAdditionalColumn) + */ + addRow(dataObject, tbody, customTableOptions) { + throw new Error('addRow must be defined'); + } + + /** + * See function for more details + */ + initShowMoreButtons(){} + + /** + * Loads rows in the members list, as well as updates pagination around the members 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 - The control for the scrollToElement functionality + * @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) { + // --------- SEARCH + let searchParams = this.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); + + // --------- FETCH DATA + // fetch json of page of domains, given params + const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null; + if (!baseUrlValue) return; + + 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; + } + + // handle the display of proper messaging in the event that no members 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 list of results will be inserted into the DOM + const tbody = this.tableWrapper.querySelector('tbody'); + tbody.innerHTML = ''; + + // remove any existing modal elements from the DOM so they can be properly re-initialized + // after the DOM content changes and there are new delete modal buttons added + this.unloadModals(); + + let dataObjects = this.getDataObjects(data); + let customTableOptions = this.customizeTable(data); + + dataObjects.forEach(dataObject => { + this.addRow(dataObject, tbody, customTableOptions); + }); + + this.initShowMoreButtons(); + + this.loadModals(data.page, data.total, data.unfiltered_total); + + // Do not scroll on first page load + if (scroll) + ScrollToElement('class', this.sectionSelector); + this.scrollToTable = true; + + // update pagination + this.updatePagination( + 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 objects:', error)); + } + + // Add event listeners to table headers for sorting initializeTableHeaders() { this.tableHeaders.forEach(header => { @@ -1400,150 +1705,82 @@ class LoadTableBase { } } -class DomainsTable extends LoadTableBase { +class DomainsTable extends BaseTable { constructor() { - super('domains'); + super('domain'); } - /** - * 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) { + getBaseUrl() { + return document.getElementById("get_domains_json_url"); + } + getDataObjects(data) { + return data.domains; + } + addRow(dataObject, tbody, customTableOptions) { + const domain = dataObject; + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; + const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; + const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; + const actionUrl = domain.action_url; + const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯'; - // fetch json of page of domais, given params - let baseUrl = document.getElementById("get_domains_json_url"); - if (!baseUrl) { - return; - } + const row = document.createElement('tr'); - let baseUrlValue = baseUrl.innerHTML; - if (!baseUrlValue) { - return; - } + let markupForSuborganizationRow = ''; - // fetch json of page of domains, given params - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "status": status, - "search_term": searchTerm - } - ); - if (portfolio) - searchParams.append("portfolio", portfolio) + if (this.portfolioValue) { + markupForSuborganizationRow = ` + + ${suborganization} + + ` + } - 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; - } - - // handle the display of proper messaging in the event that no domains 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 list will be inserted into the DOM - const domainList = document.querySelector('#domains tbody'); - domainList.innerHTML = ''; - - data.domains.forEach(domain => { - const options = { year: 'numeric', month: 'short', day: 'numeric' }; - const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; - const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; - const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; - const actionUrl = domain.action_url; - const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯'; - - const row = document.createElement('tr'); - - let markupForSuborganizationRow = ''; - - if (this.portfolioValue) { - markupForSuborganizationRow = ` - - ${suborganization} - - ` - } - - row.innerHTML = ` - - ${domain.name} - - - ${expirationDateFormatted} - - - ${domain.state_display} - - - - - ${markupForSuborganizationRow} - - - - ${domain.action_label} ${domain.name} - - - `; - domainList.appendChild(row); - }); - // initialize tool tips immediately after the associated DOM elements are added - initializeTooltips(); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', 'domains'); - this.scrollToTable = true; - - // update pagination - this.updatePagination( - 'domain', - '#domains-pagination', - '#domains-pagination .usa-pagination__counter', - '#domains', - 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 domains:', error)); + row.innerHTML = ` + + ${domain.name} + + + ${expirationDateFormatted} + + + ${domain.state_display} + + + + + ${markupForSuborganizationRow} + + + + ${domain.action_label} ${domain.name} + + + `; + tbody.appendChild(row); } } -class DomainRequestsTable extends LoadTableBase { +class DomainRequestsTable extends BaseTable { constructor() { - super('domain-requests'); + super('domain-request'); } + getBaseUrl() { + return document.getElementById("get_domain_requests_json_url"); + } + toggleExportButton(requests) { const exportButton = document.getElementById('export-csv'); if (exportButton) { @@ -1553,327 +1790,136 @@ class DomainRequestsTable extends LoadTableBase { hideElement(exportButton); } } -} + } - /** - * 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; - } + getDataObjects(data) { + return data.domain_requests; + } + unloadModals() { + uswdsUnloadModals(); + } + customizeTable(data) { - let baseUrlValue = baseUrl.innerHTML; - if (!baseUrlValue) { - return; - } + // Manage "export as CSV" visibility for domain requests + this.toggleExportButton(data.domain_requests); - // add searchParams - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "status": status, - "search_term": searchTerm + let 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 width-5'); + delheader.innerHTML = ` + Delete Action`; + let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); + tableHeaderRow.appendChild(delheader); } - ); - if (portfolio) - searchParams.append("portfolio", portfolio) + } + return { 'needsAdditionalColumn': needsDeleteColumn }; + } - 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; + addRow(dataObject, tbody, customTableOptions) { + const request = dataObject; + 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) + // If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user + let modalTrigger = ` + Domain request cannot be deleted now. Edit the request for more information.`; + + let markupCreatorRow = ''; + + if (this.portfolioValue) { + markupCreatorRow = ` + + ${request.creator ? request.creator : ''} + + ` + } + + if (request.is_deletable) { + // 1st path: Just a modal trigger in any screen size for non-org users + modalTrigger = ` + + Delete ${domainName} + ` + + // 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) { + + // 2nd path: Just a modal trigger on mobile for org users or kebab + accordion with nested modal trigger on desktop for org users + modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', domainName); + } + } + + const row = document.createElement('tr'); + row.innerHTML = ` + + ${domainName} + + + ${submissionDate} + + ${markupCreatorRow} + + ${request.status} + + + + + ${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'} + + + ${customTableOptions.needsAdditionalColumn ? ''+modalTrigger+'' : ''} + `; + tbody.appendChild(row); + if (request.is_deletable) DomainRequestsTable.addDomainRequestsModal(request.requested_domain, request.id, request.created_at, tbody); + } + + loadModals(page, total, unfiltered_total) { + // initialize modals immediately after the DOM content is updated + uswdsInitializeModals(); + + // 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'); + // Workaround: 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 = page; + if (total == 1 && unfiltered_total > 1) { + pageToDisplay--; } - - // Manage "export as CSV" visibility for domain requests - this.toggleExportButton(data.domain_requests); - - // 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 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 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) - // If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user - let modalTrigger = ` - Domain request cannot be deleted now. Edit the request for more information.`; - - let markupCreatorRow = ''; - - if (this.portfolioValue) { - markupCreatorRow = ` - - ${request.creator ? request.creator : ''} - - ` - } - - if (request.is_deletable) { - // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages - 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 = ` -
-
- -
- -
- -
- -
- ` - - 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 - if (this.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', () => { - 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)); + this.deleteDomainRequest(pk, pageToDisplay); + }); + }); } /** @@ -1903,18 +1949,186 @@ class DomainRequestsTable extends LoadTableBase { throw new Error(`HTTP error! status: ${response.status}`); } // Update data and UI - this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentSearchTerm); + this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm); }) .catch(error => console.error('Error fetching domain requests:', error)); } + + /** + * Modal that displays when deleting a domain request + * @param {string} requested_domain - The requested domain URL + * @param {string} id - The request's ID + * @param {string}} created_at - When the request was created at + * @param {HTMLElement} wrapper_element - The element to which the modal is appended + */ + static addDomainRequestsModal(requested_domain, id, created_at, wrapper_element) { + // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages + let modalHeading = ''; + let modalDescription = ''; + + if (requested_domain) { + modalHeading = `Are you sure you want to delete ${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(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.'; + } + } + + const modalSubmit = ` + + ` + + addModal(`toggle-delete-domain-${id}`, 'Are you sure you want to continue?', 'Domain will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true); + + } } -class MembersTable extends LoadTableBase { +class MembersTable extends BaseTable { constructor() { - super('members'); + super('member'); } + getBaseUrl() { + return document.getElementById("get_members_json_url"); + } + + // Abstract method (to be implemented in the child class) + getDataObjects(data) { + return data.members; + } + unloadModals() { + uswdsUnloadModals(); + } + loadModals(page, total, unfiltered_total) { + // initialize modals immediately after the DOM content is updated + uswdsInitializeModals(); + + // 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 = page; + if (total == 1 && unfiltered_total > 1) { + pageToDisplay--; + } + + this.deleteMember(pk, pageToDisplay); + }); + }); + } + + customizeTable(data) { + // Get whether the logged in user has edit members permission + const hasEditPermission = this.portfolioElement ? this.portfolioElement.getAttribute('data-has-edit-permission')==='True' : null; + + let existingExtraActionsHeader = document.querySelector('.extra-actions-header'); + + if (hasEditPermission && !existingExtraActionsHeader) { + const extraActionsHeader = document.createElement('th'); + extraActionsHeader.setAttribute('id', 'extra-actions'); + extraActionsHeader.setAttribute('role', 'columnheader'); + extraActionsHeader.setAttribute('class', 'extra-actions-header width-5'); + extraActionsHeader.innerHTML = ` + Extra Actions`; + let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); + tableHeaderRow.appendChild(extraActionsHeader); + } + return { + 'needsAdditionalColumn': hasEditPermission, + 'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices + }; + } + + addRow(dataObject, tbody, customTableOptions) { + const member = dataObject; + // member is based on either a UserPortfolioPermission or a PortfolioInvitation + // and also includes information from related domains; the 'id' of the org_member + // is the id of the UserPorfolioPermission or PortfolioInvitation, it is not a user id + // member.type is either invitedmember or member + const unique_id = member.type + member.id; // unique string for use in dom, this is + // not the id of the associated user + const member_delete_url = member.action_url + "/delete"; + const num_domains = member.domain_urls.length; + const last_active = this.handleLastActive(member.last_active); + let cancelInvitationButton = member.type === "invitedmember" ? "Cancel invitation" : "Remove member"; + const kebabHTML = customTableOptions.needsAdditionalColumn ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member.name}`): ''; + + const row = document.createElement('tr'); + + let admin_tagHTML = ``; + if (member.is_admin) + admin_tagHTML = `Admin` + + // generate html blocks for domains and permissions for the member + let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url); + let permissionsHTML = this.generatePermissionsHTML(member.permissions, customTableOptions.UserPortfolioPermissionChoices); + + // domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand + let showMoreButton = ''; + const showMoreRow = document.createElement('tr'); + if (domainsHTML || permissionsHTML) { + showMoreButton = ` + + `; + + showMoreRow.innerHTML = `
${domainsHTML} ${permissionsHTML}
`; + showMoreRow.classList.add('show-more-content'); + showMoreRow.classList.add('display-none'); + showMoreRow.id = unique_id; + } + + row.innerHTML = ` + + ${member.member_display} ${admin_tagHTML} ${showMoreButton} + + + ${last_active.display_value} + + + + + ${member.action_label} ${member.name} + + + ${customTableOptions.needsAdditionalColumn ? ''+kebabHTML+'' : ''} + `; + tbody.appendChild(row); + if (domainsHTML || permissionsHTML) { + tbody.appendChild(showMoreRow); + } + // This easter egg is only for fixtures that dont have names as we are displaying their emails + // All prod users will have emails linked to their account + if (customTableOptions.needsAdditionalColumn) MembersTable.addMemberModal(num_domains, member.email || "Samwise Gamgee", member_delete_url, unique_id, row); + } + /** * Initializes "Show More" buttons on the page, enabling toggle functionality to show or hide content. * @@ -2042,6 +2256,86 @@ class MembersTable extends LoadTableBase { return domainsHTML; } + /** + * The POST call for deleting a Member and which error or success message it should return + * and redirection if necessary + * + * @param {string} member_delete_url - The URL for deletion ie `${member_type}-${member_id}/delete`` + * @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 + * Note: X-Request-With is used for security reasons to present CSRF attacks, the server checks that this header is present + * (consent via CORS) so it knows it's not from a random request attempt + */ + deleteMember(member_delete_url, pageToDisplay) { + // Get CSRF token + const csrfToken = getCsrfToken(); + // Create FormData object and append the CSRF token + const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}`; + + fetch(`${member_delete_url}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': csrfToken, + }, + body: formData + }) + .then(response => { + if (response.status === 200) { + response.json().then(data => { + if (data.success) { + this.addAlert("success", data.success); + } + this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm); + }); + } else { + response.json().then(data => { + if (data.error) { + // This should display the error given from backend for + // either only admin OR in progress requests + this.addAlert("error", data.error); + } else { + throw new Error(`Unexpected status: ${response.status}`); + } + }); + } + }) + .catch(error => { + console.error('Error deleting member:', error); + }); + } + + + /** + * Adds an alert message to the page with an alert class. + * + * @param {string} alertClass - {error, warning, info, success} + * @param {string} alertMessage - The text that will be displayed + * + */ + addAlert(alertClass, alertMessage) { + let toggleableAlertDiv = document.getElementById("toggleable-alert"); + this.resetAlerts(); + toggleableAlertDiv.classList.add(`usa-alert--${alertClass}`); + let alertParagraph = toggleableAlertDiv.querySelector(".usa-alert__text"); + alertParagraph.innerHTML = alertMessage + showElement(toggleableAlertDiv); + } + + /** + * Resets the reusable alert message + */ + resetAlerts() { + // Create a list of any alert that's leftover and remove + document.querySelectorAll(".usa-alert:not(#toggleable-alert)").forEach(alert => { + alert.remove(); + }); + let toggleableAlertDiv = document.getElementById("toggleable-alert"); + toggleableAlertDiv.classList.remove('usa-alert--error'); + toggleableAlertDiv.classList.remove('usa-alert--success'); + hideElement(toggleableAlertDiv); + } + /** * Generates an HTML string summarizing a user's additional permissions within a portfolio, * based on the user's permissions and predefined permission choices. @@ -2105,266 +2399,68 @@ class MembersTable extends LoadTableBase { return permissionsHTML; } - /** - * Loads rows in the members list, as well as updates pagination around the members 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 - * @param {*} portfolio - the portfolio id - */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + /** + * Modal that displays when deleting a domain request + * @param {string} num_domains - Number of domain a user has within the org + * @param {string} member_email - The member's email + * @param {string} submit_delete_url - `${member_type}-${member_id}/delete` + * @param {HTMLElement} wrapper_element - The element to which the modal is appended + */ + static addMemberModal(num_domains, member_email, submit_delete_url, id, wrapper_element) { + let modalHeading = ''; + let modalDescription = ''; + + if (num_domains == 0){ + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `They will no longer be able to access this organization. + This action cannot be undone.`; + } else if (num_domains == 1) { + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `${member_email} currently manages ${num_domains} domain in the organization. + Removing them from the organization will remove all of their domains. They will no longer be able to + access this organization. This action cannot be undone.`; + } else if (num_domains > 1) { + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `${member_email} currently manages ${num_domains} domains in the organization. + Removing them from the organization will remove all of their domains. They will no longer be able to + access this organization. This action cannot be undone.`; + } - // --------- SEARCH - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "search_term": searchTerm - } - ); - if (portfolio) - searchParams.append("portfolio", portfolio) + const modalSubmit = ` + + ` - - // --------- FETCH DATA - // fetch json of page of domains, given params - let baseUrl = document.getElementById("get_members_json_url"); - if (!baseUrl) { - return; - } - - let baseUrlValue = baseUrl.innerHTML; - if (!baseUrlValue) { - return; - } - - let url = `${baseUrlValue}?${searchParams.toString()}` //TODO: uncomment for search function - 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 members 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 list will be inserted into the DOM - const memberList = document.querySelector('#members tbody'); - memberList.innerHTML = ''; - - const UserPortfolioPermissionChoices = data.UserPortfolioPermissionChoices; - const invited = 'Invited'; - const invalid_date = 'Invalid date'; - - data.members.forEach(member => { - const member_id = member.source + member.id; - const member_name = member.name; - const member_display = member.member_display; - const member_permissions = member.permissions; - const domain_urls = member.domain_urls; - const domain_names = member.domain_names; - const num_domains = domain_urls.length; - - const last_active = this.handleLastActive(member.last_active); - - const action_url = member.action_url; - const action_label = member.action_label; - const svg_icon = member.svg_icon; - - const row = document.createElement('tr'); - - let admin_tagHTML = ``; - if (member.is_admin) - admin_tagHTML = `Admin` - - // generate html blocks for domains and permissions for the member - let domainsHTML = this.generateDomainsHTML(num_domains, domain_names, domain_urls, action_url); - let permissionsHTML = this.generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices); - - // domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand - let showMoreButton = ''; - const showMoreRow = document.createElement('tr'); - if (domainsHTML || permissionsHTML) { - showMoreButton = ` - - `; - - showMoreRow.innerHTML = `
${domainsHTML} ${permissionsHTML}
`; - showMoreRow.classList.add('show-more-content'); - showMoreRow.classList.add('display-none'); - showMoreRow.id = member_id; - } - - row.innerHTML = ` - - ${member_display} ${admin_tagHTML} ${showMoreButton} - - - ${last_active.display_value} - - - - - ${action_label} ${member_name} - - - `; - memberList.appendChild(row); - if (domainsHTML || permissionsHTML) { - memberList.appendChild(showMoreRow); - } - }); - - this.initShowMoreButtons(); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', 'members'); - this.scrollToTable = true; - - // update pagination - this.updatePagination( - 'member', - '#members-pagination', - '#members-pagination .usa-pagination__counter', - '#members', - 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 members:', error)); + addModal(`toggle-remove-member-${id}`, 'Are you sure you want to continue?', 'Member will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true); } } -class MemberDomainsTable extends LoadTableBase { +class MemberDomainsTable extends BaseTable { constructor() { - super('member-domains'); + super('member-domain'); this.currentSortBy = 'name'; } - /** - * Loads rows in the members list, as well as updates pagination around the members 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 - * @param {*} portfolio - the portfolio id - */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { - - // --------- SEARCH - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "search_term": searchTerm, - } - ); - - let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null; - let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null; - let memberOnly = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-only') : null; - - if (portfolio) - searchParams.append("portfolio", portfolio) - if (emailValue) - searchParams.append("email", emailValue) - if (memberIdValue) - searchParams.append("member_id", memberIdValue) - if (memberOnly) - searchParams.append("member_only", memberOnly) - - - // --------- FETCH DATA - // fetch json of page of domais, given params - let baseUrl = document.getElementById("get_member_domains_json_url"); - if (!baseUrl) { - return; - } - - let baseUrlValue = baseUrl.innerHTML; - if (!baseUrlValue) { - return; - } - - let url = `${baseUrlValue}?${searchParams.toString()}` //TODO: uncomment for search function - 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 members 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 list will be inserted into the DOM - const memberDomainsList = document.querySelector('#member-domains tbody'); - memberDomainsList.innerHTML = ''; - - - data.domains.forEach(domain => { - const row = document.createElement('tr'); - - row.innerHTML = ` - - ${domain.name} - - `; - memberDomainsList.appendChild(row); - }); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', 'member-domains'); - this.scrollToTable = true; - - // update pagination - this.updatePagination( - 'member domain', - '#member-domains-pagination', - '#member-domains-pagination .usa-pagination__counter', - '#member-domains', - 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 domains:', error)); + getBaseUrl() { + return document.getElementById("get_member_domains_json_url"); } + getDataObjects(data) { + return data.domains; + } + addRow(dataObject, tbody, customTableOptions) { + const domain = dataObject; + const row = document.createElement('tr'); + + row.innerHTML = ` + + ${domain.name} + + `; + tbody.appendChild(row); + } + } @@ -2776,6 +2872,46 @@ document.addEventListener('DOMContentLoaded', function() { } })(); +// This is specifically for the Member Profile (Manage Member) Page member/invitation removal +document.addEventListener("DOMContentLoaded", () => { + (function portfolioMemberPageToggle() { + const wrapperDeleteAction = document.getElementById("wrapper-delete-action") + if (wrapperDeleteAction) { + const member_type = wrapperDeleteAction.getAttribute("data-member-type"); + const member_id = wrapperDeleteAction.getAttribute("data-member-id"); + const num_domains = wrapperDeleteAction.getAttribute("data-num-domains"); + const member_name = wrapperDeleteAction.getAttribute("data-member-name"); + const member_email = wrapperDeleteAction.getAttribute("data-member-email"); + const member_delete_url = `${member_type}-${member_id}/delete`; + const unique_id = `${member_type}-${member_id}`; + + let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; + wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); + + // This easter egg is only for fixtures that dont have names as we are displaying their emails + // All prod users will have emails linked to their account + MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); + + uswdsInitializeModals(); + + // 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', () => { + closeButton.click(); + let delete_member_form = document.getElementById("member-delete-form"); + if (delete_member_form) { + delete_member_form.submit(); + } + }); + }); + } + })(); +}); + /** An IIFE that intializes the requesting entity page. * This page has a radio button that dynamically toggles some fields * Within that, the dropdown also toggles some additional form elements. @@ -2819,4 +2955,4 @@ document.addEventListener('DOMContentLoaded', function() { // Add event listener to the suborg dropdown to show/hide the suborg details section select.addEventListener("change", () => toggleSuborganization()); -})(); +})(); \ No newline at end of file diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index aa84a3405..458aa5ce0 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -93,6 +93,11 @@ urlpatterns = [ views.PortfolioMemberView.as_view(), name="member", ), + path( + "member//delete", + views.PortfolioMemberDeleteView.as_view(), + name="member-delete", + ), path( "member//permissions", views.PortfolioMemberEditView.as_view(), @@ -108,6 +113,11 @@ urlpatterns = [ views.PortfolioInvitedMemberView.as_view(), name="invitedmember", ), + path( + "invitedmember//delete", + views.PortfolioInvitedMemberDeleteView.as_view(), + name="invitedmember-delete", + ), path( "invitedmember//permissions", views.PortfolioInvitedMemberEditView.as_view(), diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 80c972d38..2d65aa02e 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -1,11 +1,12 @@ import logging +from django.apps import apps from django.contrib.auth.models import AbstractUser from django.db import models from django.db.models import Q from registrar.models import DomainInformation, UserDomainRole -from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .domain_invitation import DomainInvitation from .portfolio_invitation import PortfolioInvitation @@ -471,3 +472,42 @@ class User(AbstractUser): return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True) else: return UserDomainRole.objects.filter(user=self).values_list("id", flat=True) + + def get_active_requests_count_in_portfolio(self, request): + """Return count of active requests for the portfolio associated with the request.""" + # Get the portfolio from the session using the existing method + + portfolio = request.session.get("portfolio") + + if not portfolio: + return 0 # No portfolio found + + allowed_states = [ + DomainRequest.DomainRequestStatus.SUBMITTED, + DomainRequest.DomainRequestStatus.IN_REVIEW, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + ] + + # Now filter based on the portfolio retrieved + active_requests_count = self.domain_requests_created.filter( + status__in=allowed_states, portfolio=portfolio + ).count() + + return active_requests_count + + def is_only_admin_of_portfolio(self, portfolio): + """Check if the user is the only admin of the given portfolio.""" + + UserPortfolioPermission = apps.get_model("registrar", "UserPortfolioPermission") + + admin_permission = UserPortfolioRoleChoices.ORGANIZATION_ADMIN + + admins = UserPortfolioPermission.objects.filter(portfolio=portfolio, roles__contains=[admin_permission]) + admin_count = admins.count() + + # Check if the current user is in the list of admins + if admin_count == 1 and admins.first().user == self: + return True # The user is the only admin + + # If there are other admins or the user is not the only one + return False diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index f020fabf0..662328660 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -64,7 +64,7 @@ >
-

Are you sure you want to extend the expiration date?

@@ -128,7 +128,7 @@ >
-

Are you sure you want to place this domain on hold?

@@ -195,7 +195,7 @@ >
-

Are you sure you want to remove this domain from the registry?

diff --git a/src/registrar/templates/django/admin/domain_request_change_form.html b/src/registrar/templates/django/admin/domain_request_change_form.html index ef76ac0b1..46965f236 100644 --- a/src/registrar/templates/django/admin/domain_request_change_form.html +++ b/src/registrar/templates/django/admin/domain_request_change_form.html @@ -57,7 +57,7 @@ >
-

Are you sure you want to select ineligible status?

diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html index 5e0dc6116..066a058fc 100644 --- a/src/registrar/templates/includes/members_table.html +++ b/src/registrar/templates/includes/members_table.html @@ -1,7 +1,7 @@ {% load static %} - + {% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% url 'get_portfolio_members_json' as url %} diff --git a/src/registrar/templates/includes/modal.html b/src/registrar/templates/includes/modal.html index d918b335d..00c51cee0 100644 --- a/src/registrar/templates/includes/modal.html +++ b/src/registrar/templates/includes/modal.html @@ -2,7 +2,7 @@
-

{{ modal_heading }} {%if domain_name_modal is not None %} @@ -16,7 +16,7 @@ {% endif %}

-

{{ modal_description }}

diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index c0611f854..f492dbd2f 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -1,7 +1,9 @@ {% extends 'portfolio_base.html' %} {% load static field_helpers%} -{% block title %}Organization member {% endblock %} +{% block title %} +Organization member +{% endblock %} {% load static %} @@ -33,60 +35,30 @@ {% if has_edit_members_portfolio_permission %} {% if member %} - - Remove member - - {% else %} - - Cancel invitation - - {% endif %} - -
-
- -
- +
{% csrf_token %}
Last active: {% if member and member.last_login %} diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html index 3cd3aec44..332d1c16c 100644 --- a/src/registrar/templates/portfolio_members.html +++ b/src/registrar/templates/portfolio_members.html @@ -9,11 +9,15 @@ {% endblock %} {% block portfolio_content %} -{% block messages %} - {% include "includes/form_messages.html" %} -{% endblock %}
+

Members

diff --git a/src/registrar/templates/profile.html b/src/registrar/templates/profile.html index 6e1e7781f..7d365d9c1 100644 --- a/src/registrar/templates/profile.html +++ b/src/registrar/templates/profile.html @@ -51,11 +51,11 @@ Edit your User Profile | >
-

Add contact information

-

.Gov domain registrants must maintain accurate contact information in the .gov registrar. Before you can manage your domain, we need you to add your contact information.

diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 53206359b..0c1bdec2a 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -824,6 +824,92 @@ class TestUser(TestCase): cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required." ) + @less_console_noise_decorator + def test_get_active_requests_count_in_portfolio_returns_zero_if_no_portfolio(self): + # There is no portfolio referenced in session so should return 0 + request = self.factory.get("/") + request.session = {} + + count = self.user.get_active_requests_count_in_portfolio(request) + self.assertEqual(count, 0) + + @less_console_noise_decorator + def test_get_active_requests_count_in_portfolio_returns_count_if_portfolio(self): + request = self.factory.get("/") + request.session = {"portfolio": self.portfolio} + + # Create active requests + domain_1, _ = DraftDomain.objects.get_or_create(name="meoward1.gov") + domain_2, _ = DraftDomain.objects.get_or_create(name="meoward2.gov") + domain_3, _ = DraftDomain.objects.get_or_create(name="meoward3.gov") + domain_4, _ = DraftDomain.objects.get_or_create(name="meoward4.gov") + + # Create 3 active requests + 1 that isn't + DomainRequest.objects.create( + creator=self.user, + requested_domain=domain_1, + status=DomainRequest.DomainRequestStatus.SUBMITTED, + portfolio=self.portfolio, + ) + DomainRequest.objects.create( + creator=self.user, + requested_domain=domain_2, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + portfolio=self.portfolio, + ) + DomainRequest.objects.create( + creator=self.user, + requested_domain=domain_3, + status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, + portfolio=self.portfolio, + ) + DomainRequest.objects.create( # This one should not be counted + creator=self.user, + requested_domain=domain_4, + status=DomainRequest.DomainRequestStatus.REJECTED, + portfolio=self.portfolio, + ) + + count = self.user.get_active_requests_count_in_portfolio(request) + self.assertEqual(count, 3) + + @less_console_noise_decorator + def test_is_only_admin_of_portfolio_returns_true(self): + # Create user as the only admin of the portfolio + UserPortfolioPermission.objects.create( + user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + self.assertTrue(self.user.is_only_admin_of_portfolio(self.portfolio)) + + @less_console_noise_decorator + def test_is_only_admin_of_portfolio_returns_false_if_no_admins(self): + # No admin for the portfolio + self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio)) + + @less_console_noise_decorator + def test_is_only_admin_of_portfolio_returns_false_if_multiple_admins(self): + # Create multiple admins for the same portfolio + UserPortfolioPermission.objects.create( + user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + # Create another user within this test + other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin") + UserPortfolioPermission.objects.create( + user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio)) + + @less_console_noise_decorator + def test_is_only_admin_of_portfolio_returns_false_if_user_not_admin(self): + # Create other_user for same portfolio and is given admin access + other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin") + + UserPortfolioPermission.objects.create( + user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + # User doesn't have admin access so should return false + self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio)) + class TestContact(TestCase): @less_console_noise_decorator diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index a50a78b23..b7f455936 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2,8 +2,9 @@ from django.urls import reverse from api.tests.common import less_console_noise_decorator from registrar.config import settings from registrar.models import Portfolio, SeniorOfficial -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from django_webtest import WebTest # type: ignore +from django.core.handlers.wsgi import WSGIRequest from registrar.models import ( DomainRequest, Domain, @@ -959,7 +960,7 @@ class TestPortfolio(WebTest): ) # Assert buttons and links within the page are correct - self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present + self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present self.assertContains(response, "sprite.svg#edit") # test that Edit link is present self.assertContains(response, "sprite.svg#settings") # test that Manage link is present self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present @@ -1077,9 +1078,8 @@ class TestPortfolio(WebTest): self.assertContains( response, 'This member does not manage any domains. To assign this member a domain, click "Manage"' ) - # Assert buttons and links within the page are correct - self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present + self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present self.assertContains(response, "sprite.svg#edit") # test that Edit link is present self.assertContains(response, "sprite.svg#settings") # test that Manage link is present self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present @@ -1392,6 +1392,510 @@ class TestPortfolio(WebTest): self.assertTrue(DomainRequest.objects.filter(pk=domain_request.pk).exists()) domain_request.delete() + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_members_table_contains_hidden_permissions_js_hook(self): + # In the members_table.html we use data-has-edit-permission as a boolean + # to indicate if a user has permission to edit members in the specific portfolio + + # 1. User w/ edit permission + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Create a member under same portfolio + member_email = "a_member@example.com" + member, _ = User.objects.get_or_create(username="a_member", email=member_email) + + UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # I log in as the User so I can see the Members Table + self.client.force_login(self.user) + + # Specifically go to the Member Table page + response = self.client.get(reverse("members")) + + self.assertContains(response, 'data-has-edit-permission="True"') + + # 2. User w/o edit permission (additional permission of EDIT_MEMBERS removed) + permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio) + + # Remove the EDIT_MEMBERS additional permission + permission.additional_permissions = [ + perm for perm in permission.additional_permissions if perm != UserPortfolioPermissionChoices.EDIT_MEMBERS + ] + + # Save the updated permissions list + permission.save() + + # Re-fetch the page to check for updated permissions + response = self.client.get(reverse("members")) + + self.assertContains(response, 'data-has-edit-permission="False"') + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_page_has_kebab_wrapper_for_member_if_user_has_edit_permission(self): + """Test that the kebab wrapper displays for a member with edit permissions""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Create a member under same portfolio + member_email = "a_member@example.com" + member, _ = User.objects.get_or_create(username="a_member", email=member_email) + + upp, _ = UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # I log in as the User so I can see the Manage Member page + self.client.force_login(self.user) + + # Specifically go to the Manage Member page + response = self.client.get(reverse("member", args=[upp.id]), follow=True) + + self.assertEqual(response.status_code, 200) + + # Check for email AND member type (which here is just member) + self.assertContains(response, f'data-member-name="{member_email}"') + self.assertContains(response, 'data-member-type="member"') + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_page_has_kebab_wrapper_for_invited_member_if_user_has_edit_permission(self): + """Test that the kebab wrapper displays for an invitedmember with edit permissions""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Invite a member under same portfolio + invited_member_email = "invited_member@example.com" + invitation = PortfolioInvitation.objects.create( + email=invited_member_email, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # I log in as the User so I can see the Manage Member page + self.client.force_login(self.user) + response = self.client.get(reverse("invitedmember", args=[invitation.id]), follow=True) + + self.assertEqual(response.status_code, 200) + + # Assert the invited members email + invitedmember type + self.assertContains(response, f'data-member-name="{invited_member_email}"') + self.assertContains(response, 'data-member-type="invitedmember"') + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_page_does_not_have_kebab_wrapper(self): + """Test that the kebab does not display.""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # That creates a member with only view access + member_email = "member_with_view_access@example.com" + member, _ = User.objects.get_or_create(username="test_member_with_view_access", email=member_email) + + upp, _ = UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + + # I log in as the Member with only view permissions to evaluate the pages behaviour + # when viewed by someone who doesn't have edit perms + self.client.force_login(member) + + # Go to the Manage Member page + response = self.client.get(reverse("member", args=[upp.id]), follow=True) + + self.assertEqual(response.status_code, 200) + + # Assert that the kebab edit options are unavailable + self.assertNotContains(response, 'data-member-type="member"') + self.assertNotContains(response, 'data-member-type="invitedmember"') + self.assertNotContains(response, f'data-member-name="{member_email}"') + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_page_has_correct_form_wrapper(self): + """Test that the manage members page the right form wrapper""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # That creates a member + member_email = "a_member@example.com" + member, _ = User.objects.get_or_create(email=member_email) + + upp, _ = UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # Login as the User to see the Manage Member page + self.client.force_login(self.user) + + # Specifically go to the Manage Member page + response = self.client.get(reverse("member", args=[upp.id]), follow=True) + + # Check for a 200 response + self.assertEqual(response.status_code, 200) + + # Check for form method + that its "post" and id "member-delete-form" + self.assertContains(response, "Contact the .gov team to remove them." + ) + + self.assertContains(response, expected_error_message, status_code=400) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_portfolio_member_delete_view_members_table_only_admin(self): + """Error state w/ deleting a member that's the only admin on Members Table""" + + # I'm a user with admin permission + admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + with patch.object(User, "is_only_admin_of_portfolio", return_value=True): + self.client.force_login(self.user) + # We check X_REQUESTED_WITH bc those return JSON responses + response = self.client.post( + reverse("member-delete", kwargs={"pk": admin_perm_user.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + + self.assertEqual(response.status_code, 400) + expected_error_message = ( + "There must be at least one admin in your organization. Give another member admin " + "permissions, make sure they log into the registrar, and then remove this member." + ) + self.assertContains(response, expected_error_message, status_code=400) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_portfolio_member_table_delete_view_success(self): + """Success state with deleting on Members Table page bc no active request AND not only admin""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Creating a member that can be deleted (see patch) + member_email = "deleteable_member@example.com" + member, _ = User.objects.get_or_create(email=member_email) + + # Set up the member in the portfolio + upp, _ = UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # And set that the member has no active requests AND it's not the only admin + with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object( + User, "is_only_admin_of_portfolio", return_value=False + ): + + # Attempt to delete + self.client.force_login(self.user) + response = self.client.post( + # We check X_REQUESTED_WITH bc those return JSON responses + reverse("member-delete", kwargs={"pk": upp.pk}), + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + # Check for a successful deletion + self.assertEqual(response.status_code, 200) + + expected_success_message = f"You've removed {member.email} from the organization." + self.assertContains(response, expected_success_message, status_code=200) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_portfolio_member_delete_view_manage_members_page_active_requests(self): + """Error state when deleting a member with active requests on the Manage Members page""" + + # I'm an admin user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Create a member with active requests + member_email = "member_with_active_request@example.com" + member, _ = User.objects.get_or_create(email=member_email) + + upp, _ = UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + with patch.object(User, "get_active_requests_count_in_portfolio", return_value=1): + with patch("django.contrib.messages.error") as mock_error: + self.client.force_login(self.user) + response = self.client.post( + reverse("member-delete", kwargs={"pk": upp.pk}), + ) + # We don't want to do follow=True in response bc that does automatic redirection + + # We want 302 bc indicates redirect + self.assertEqual(response.status_code, 302) + + support_url = "https://get.gov/contact/" + expected_error_message = ( + f"This member has an active domain request and can't be removed from the organization. " + f"Contact the .gov team to remove them." + ) + + args, kwargs = mock_error.call_args + # Check if first arg is a WSGIRequest, confirms request object passed correctly + # WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete') + self.assertIsInstance(args[0], WSGIRequest) + # Check that the error message matches the expected error message + self.assertEqual(args[1], expected_error_message) + + # Location is used for a 3xx HTTP status code to indicate that the URL was redirected + # and then confirm that we're still on the Manage Members page + self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": upp.pk})) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_portfolio_member_delete_view_manage_members_page_only_admin(self): + """Error state when trying to delete the only admin on the Manage Members page""" + + # Create an admin with admin user perms + admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Set them to be the only admin and attempt to delete + with patch.object(User, "is_only_admin_of_portfolio", return_value=True): + with patch("django.contrib.messages.error") as mock_error: + self.client.force_login(self.user) + response = self.client.post( + reverse("member-delete", kwargs={"pk": admin_perm_user.pk}), + ) + + self.assertEqual(response.status_code, 302) + + expected_error_message = ( + "There must be at least one admin in your organization. Give another member admin " + "permissions, make sure they log into the registrar, and then remove this member." + ) + + args, kwargs = mock_error.call_args + # Check if first arg is a WSGIRequest, confirms request object passed correctly + # WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete') + self.assertIsInstance(args[0], WSGIRequest) + # Check that the error message matches the expected error message + self.assertEqual(args[1], expected_error_message) + + # Location is used for a 3xx HTTP status code to indicate that the URL was redirected + # and then confirm that we're still on the Manage Members page + self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": admin_perm_user.pk})) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_portfolio_member_delete_view_manage_members_page_invitedmember(self): + """Success state w/ deleting invited member on Manage Members page should redirect back to Members Table""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Invite a member under same portfolio + invited_member_email = "invited_member@example.com" + invitation = PortfolioInvitation.objects.create( + email=invited_member_email, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + with patch("django.contrib.messages.success") as mock_success: + self.client.force_login(self.user) + response = self.client.post( + reverse("invitedmember-delete", kwargs={"pk": invitation.pk}), + ) + + self.assertEqual(response.status_code, 302) + + expected_success_message = f"You've removed {invitation.email} from the organization." + args, kwargs = mock_success.call_args + # Check if first arg is a WSGIRequest, confirms request object passed correctly + # WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete') + self.assertIsInstance(args[0], WSGIRequest) + # Check that the error message matches the expected error message + self.assertEqual(args[1], expected_success_message) + + # Location is used for a 3xx HTTP status code to indicate that the URL was redirected + # and then confirm that we're now on Members Table page + self.assertEqual(response.headers["Location"], reverse("members")) + class TestPortfolioMemberDomainsView(TestWithUser, WebTest): @classmethod diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 17209f0d9..512124377 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -100,7 +100,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): user__permissions__domain__domain_info__portfolio=portfolio ), # only include domains in portfolio ), - source=Value("permission", output_field=CharField()), + type=Value("member", output_field=CharField()), ) .values( "id", @@ -112,7 +112,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): "additional_permissions_display", "member_display", "domain_info", - "source", + "type", ) ) return permissions @@ -140,7 +140,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): distinct=True, ) ), - source=Value("invitation", output_field=CharField()), + type=Value("invitedmember", output_field=CharField()), ).values( "id", "first_name", @@ -151,7 +151,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): "additional_permissions_display", "member_display", "domain_info", - "source", + "type", ) return invitations @@ -188,12 +188,12 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or []) - action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]}) + action_url = reverse(item["type"], kwargs={"pk": item["id"]}) # Serialize member data member_json = { - "id": item.get("id", ""), - "source": item.get("source", ""), + "id": item.get("id", ""), # id is id of UserPortfolioPermission or PortfolioInvitation + "type": item.get("type", ""), # source is member or invitedmember "name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])), "email": item.get("email_display", ""), "member_display": item.get("member_display", ""), diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 7839d209e..373d1619d 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -1,13 +1,17 @@ import logging -from django.http import Http404 + +from django.http import Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils.safestring import mark_safe from django.contrib import messages + from registrar.forms import portfolio as portfolioForms from registrar.models import Portfolio, User from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices +from registrar.views.utility.mixins import PortfolioMemberPermission from registrar.views.utility.permission_views import ( PortfolioDomainRequestsPermissionView, PortfolioDomainsPermissionView, @@ -81,6 +85,58 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View): ) +class PortfolioMemberDeleteView(PortfolioMemberPermission, View): + + def post(self, request, pk): + """ + Find and delete the portfolio member using the provided primary key (pk). + Redirect to a success page after deletion (or any other appropriate page). + """ + portfolio_member_permission = get_object_or_404(UserPortfolioPermission, pk=pk) + member = portfolio_member_permission.user + + active_requests_count = member.get_active_requests_count_in_portfolio(request) + + support_url = "https://get.gov/contact/" + + error_message = "" + + if active_requests_count > 0: + # If they have any in progress requests + error_message = mark_safe( # nosec + f"This member has an active domain request and can't be removed from the organization. " + f"Contact the .gov team to remove them." + ) + elif member.is_only_admin_of_portfolio(portfolio_member_permission.portfolio): + # If they are the last manager of a domain + error_message = ( + "There must be at least one admin in your organization. Give another member admin " + "permissions, make sure they log into the registrar, and then remove this member." + ) + + # From the Members Table page Else the Member Page + if error_message: + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse( + {"error": error_message}, + status=400, + ) + else: + messages.error(request, error_message) + return redirect(reverse("member", kwargs={"pk": pk})) + + # passed all error conditions + portfolio_member_permission.delete() + + # From the Members Table page Else the Member Page + success_message = f"You've removed {member.email} from the organization." + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": success_message}, status=200) + else: + messages.success(request, success_message) + return redirect(reverse("members")) + + class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): template_name = "portfolio_member_permissions.html" @@ -177,6 +233,26 @@ class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View): ) +class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View): + + def post(self, request, pk): + """ + Find and delete the portfolio invited member using the provided primary key (pk). + Redirect to a success page after deletion (or any other appropriate page). + """ + portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) + + portfolio_invitation.delete() + + success_message = f"You've removed {portfolio_invitation.email} from the organization." + # From the Members Table page Else the Member Page + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": success_message}, status=200) + else: + messages.success(request, success_message) + return redirect(reverse("members")) + + class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View): template_name = "portfolio_member_permissions.html"