diff --git a/docs/operations/runbooks/downtime_incident_management.md b/docs/operations/runbooks/downtime_incident_management.md
index 4aa884e9d..8ace9fa11 100644
--- a/docs/operations/runbooks/downtime_incident_management.md
+++ b/docs/operations/runbooks/downtime_incident_management.md
@@ -16,6 +16,8 @@ The following set of rules should be followed while an incident is in progress.
- If downtime occurs outside of working hours, team members who are off for the day may still be pinged and called but are not required to join if unavailable to do so.
- Uncomment the [banner on get.gov](https://github.com/cisagov/get.gov/blob/0365d3d34b041cc9353497b2b5f81b6ab7fe75a9/_includes/header.html#L9), so it is transparent to users that we know about the issue on manage.get.gov.
- Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug.
+- Uncomment the [banner on manage.get.gov's base template](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/base.html#L78).
+ - Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug.
- If the issue persists for three hours or more, follow the [instructions for enabling/disabling a redirect to get.gov](https://docs.google.com/document/d/1PiWXpjBzbiKsSYqEo9Rkl72HMytMp7zTte9CI-vvwYw/edit).
## Post Incident
diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index adcc21d2a..805d29cbd 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)}
+
+
+
+
+
+
+
More options
+ ${generateModalButton()}
+
+
+ `;
+
+ 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 = `
`;
- 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 = `
-
+ `;
+ 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 = `
+
' : ''}
+ `;
+ 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 = `
-
-
-
- ${modalHeading}
-
-
-
- ${modalDescription}
-
-
-
-
-
-
- `
-
- 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}
-
-
-
' : ''}
- `;
- 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 (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 = `
' : ''}
+ `;
+ 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 = `
Are you sure you want to select ineligible status?
diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
index 1cb835988..d5b1130ab 100644
--- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
+++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html
@@ -359,7 +359,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
+ Month day, time-in-24-hour-notation UTC: We're investigating a service disruption on the .gov registrar. The .gov zone and individual domains remain online. However, the registrar is running slower than usual.
+
+ Oct 16, 24:00 UTC: We're investigating an outage on the .gov registrar. The .gov zone and individual domains remain online. However, you can't request a new domain or manage an existing one at this time.
+
+
+
+
diff --git a/src/registrar/templates/includes/banner-warning.html b/src/registrar/templates/includes/banner-warning.html
new file mode 100644
index 000000000..6838aef7b
--- /dev/null
+++ b/src/registrar/templates/includes/banner-warning.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Header
+
+
+ Text here
+
+
+
+
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 %}
{{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 %}
+
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_domain.py b/src/registrar/tests/test_views_domain.py
index 15a786c8b..678d5be82 100644
--- a/src/registrar/tests/test_views_domain.py
+++ b/src/registrar/tests/test_views_domain.py
@@ -323,6 +323,27 @@ class TestDomainDetail(TestDomainOverview):
self.assertContains(detail_page, "noinformation.gov")
self.assertContains(detail_page, "Domain missing domain information")
+ def test_domain_detail_with_analyst_managing_domain(self):
+ """Test that domain management page returns 200 and does not display
+ blue error message when an analyst is managing the domain"""
+ with less_console_noise():
+ staff_user = create_user()
+ self.client.force_login(staff_user)
+
+ # need to set the analyst_action and analyst_action_location
+ # in the session to emulate user clicking Manage Domain
+ # in the admin interface
+ session = self.client.session
+ session["analyst_action"] = "edit"
+ session["analyst_action_location"] = self.domain.id
+ session.save()
+
+ detail_page = self.client.get(reverse("domain", kwargs={"pk": self.domain.id}))
+
+ self.assertNotContains(
+ detail_page, "To manage information for this domain, you must add yourself as a domain manager."
+ )
+
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
def test_domain_readonly_on_detail_page(self):
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, "