diff --git a/src/registrar/assets/modules/helpers-uswds.js b/src/registrar/assets/modules/helpers-uswds.js
index e182d65c0..bb861ab9c 100644
--- a/src/registrar/assets/modules/helpers-uswds.js
+++ b/src/registrar/assets/modules/helpers-uswds.js
@@ -21,13 +21,13 @@ export 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
+ * uswdsInitializeModals 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 uswdsInitializeModals.
*
*/
-export function initializeModals() {
+export function uswdsInitializeModals() {
window.modal.on();
}
@@ -35,9 +35,9 @@ export function initializeModals() {
* 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 uswdsInitializeModals.
*
*/
-export function unloadModals() {
+export function uswdsUnloadModals() {
window.modal.off();
}
diff --git a/src/registrar/assets/modules/helpers.js b/src/registrar/assets/modules/helpers.js
index f02605269..ea8fe5661 100644
--- a/src/registrar/assets/modules/helpers.js
+++ b/src/registrar/assets/modules/helpers.js
@@ -7,7 +7,7 @@ export function showElement(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
*/
diff --git a/src/registrar/assets/modules/main.js b/src/registrar/assets/modules/main.js
index f99ef188c..cd89540ab 100644
--- a/src/registrar/assets/modules/main.js
+++ b/src/registrar/assets/modules/main.js
@@ -9,6 +9,7 @@ import { initDomainsTable } from './table-domains.js';
import { initDomainRequestsTable } from './table-domain-requests.js';
import { initMembersTable } from './table-members.js';
import { initMemberDomainsTable } from './table-member-domains.js';
+import { initPortfolioMemberPageToggle } from './portfolio-member-page.js';
initDomainValidators();
@@ -40,3 +41,5 @@ initDomainsTable();
initDomainRequestsTable();
initMembersTable();
initMemberDomainsTable();
+
+initPortfolioMemberPageToggle();
diff --git a/src/registrar/assets/modules/portfolio-member-page.js b/src/registrar/assets/modules/portfolio-member-page.js
new file mode 100644
index 000000000..023836135
--- /dev/null
+++ b/src/registrar/assets/modules/portfolio-member-page.js
@@ -0,0 +1,42 @@
+import { uswdsInitializeModals } from './helpers-uswds.js';
+import { generateKebabHTML } from './table-base.js';
+
+// This is specifically for the Member Profile (Manage Member) Page member/invitation removal
+export function initPortfolioMemberPageToggle() {
+ document.addEventListener("DOMContentLoaded", () => {
+ 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();
+ }
+ });
+ });
+ }
+ });
+}
diff --git a/src/registrar/assets/modules/table-base.js b/src/registrar/assets/modules/table-base.js
index ed9cdc655..84cf64663 100644
--- a/src/registrar/assets/modules/table-base.js
+++ b/src/registrar/assets/modules/table-base.js
@@ -1,24 +1,149 @@
-import { hideElement, showElement, toggleCaret } from './helpers.js';
+import { hideElement, showElement, toggleCaret, scrollToElement } from './helpers.js';
-export class LoadTableBase {
+/**
+* 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.
+*/
+export 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
+ */
+export 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;
+}
+
+export class BaseTable {
constructor(sectionSelector) {
- this.tableWrapper = document.getElementById(`${sectionSelector}__table-wrapper`);
- this.tableHeaders = document.querySelectorAll(`#${sectionSelector} th[data-sortable]`);
+ 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();
@@ -31,31 +156,24 @@ export 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 = '';
@@ -65,12 +183,30 @@ export 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
+ 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';
@@ -99,7 +235,7 @@ export 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
@@ -111,13 +247,13 @@ export 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;
@@ -156,24 +295,6 @@ export 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
*
@@ -187,9 +308,181 @@ export 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
+ */
+ uswdsUnloadModals(){}
+
+ /**
+ * 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
diff --git a/src/registrar/assets/modules/table-domain-requests.js b/src/registrar/assets/modules/table-domain-requests.js
index 55b2c951c..e408bbd1b 100644
--- a/src/registrar/assets/modules/table-domain-requests.js
+++ b/src/registrar/assets/modules/table-domain-requests.js
@@ -1,8 +1,8 @@
-import { hideElement, showElement, scrollToElement } from './helpers.js';
-import { initializeModals, unloadModals } from './helpers-uswds.js';
+import { hideElement, showElement } from './helpers.js';
+import { uswdsInitializeModals, uswdsUnloadModals } from './helpers-uswds.js';
import { getCsrfToken } from './helpers-csrf-token.js';
-import { LoadTableBase } from './table-base.js';
+import { BaseTable, addModal, generateKebabHTML } from './table-base.js';
const utcDateString = (dateString) => {
@@ -20,10 +20,14 @@ const utcDateString = (dateString) => {
};
-export class DomainRequestsTable extends LoadTableBase {
+export class DomainRequestsTable extends BaseTable {
constructor() {
- super('domain-requests');
+ super('domain-request');
+ }
+
+ getBaseUrl() {
+ return document.getElementById("get_domain_requests_json_url");
}
toggleExportButton(requests) {
@@ -35,327 +39,137 @@ export 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);
+ }
+
}
export function initMemberDomainsTable() {
diff --git a/src/registrar/assets/modules/table-members.js b/src/registrar/assets/modules/table-members.js
index f75b4c9e3..d3bc1294b 100644
--- a/src/registrar/assets/modules/table-members.js
+++ b/src/registrar/assets/modules/table-members.js
@@ -1,13 +1,145 @@
-import { hideElement, showElement, scrollToElement } from './helpers.js';
+import { hideElement, showElement } from './helpers.js';
-import { LoadTableBase } from './table-base.js';
+import { BaseTable, addModal, generateKebabHTML } from './table-base.js';
-export class MembersTable extends LoadTableBase {
+export 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.
*
@@ -135,6 +267,86 @@ export 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.
@@ -199,157 +411,40 @@ export class MembersTable extends LoadTableBase {
}
/**
- * 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 = '';
- // --------- SEARCH
- let searchParams = new URLSearchParams(
- {
- "page": page,
- "sort_by": sortBy,
- "order": order,
- "search_term": searchTerm
- }
- );
- if (portfolio)
- searchParams.append("portfolio", portfolio)
+ 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.`;
+ }
+ 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 = `