diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index 4621c5ac5..a815a59a1 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -15,8 +15,8 @@ function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, a // Revert the dropdown to its previous value statusDropdown.value = valueToCheck; }); - }else { - console.log("displayModalOnDropdownClick() -> Cancel button was null"); + } else { + console.warn("displayModalOnDropdownClick() -> Cancel button was null"); } // Add a change event listener to the dropdown. diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 5de02f35a..bd4bed01b 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/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 { initEditMemberDomainsTable } from './table-edit-member-domains.js'; import { initPortfolioMemberPageToggle } from './portfolio-member-page.js'; import { initAddNewMemberPageListeners } from './portfolio-member-page.js'; @@ -41,6 +42,7 @@ initDomainsTable(); initDomainRequestsTable(); initMembersTable(); initMemberDomainsTable(); +initEditMemberDomainsTable(); initPortfolioMemberPageToggle(); initAddNewMemberPageListeners(); diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index ac0b7cffe..ba874cfb1 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -49,7 +49,7 @@ export function initPortfolioMemberPageToggle() { * on the Add New Member page. */ export function initAddNewMemberPageListeners() { - add_member_form = document.getElementById("add_member_form") + let add_member_form = document.getElementById("add_member_form"); if (!add_member_form){ return; } diff --git a/src/registrar/assets/src/js/getgov/table-base.js b/src/registrar/assets/src/js/getgov/table-base.js index 07b7cff5e..e526c6b5f 100644 --- a/src/registrar/assets/src/js/getgov/table-base.js +++ b/src/registrar/assets/src/js/getgov/table-base.js @@ -126,6 +126,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r export class BaseTable { constructor(itemName) { this.itemName = itemName; + this.displayName = itemName; this.sectionSelector = itemName + 's'; this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`); this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`); @@ -183,7 +184,7 @@ export class BaseTable { // Counter should only be displayed if there is more than 1 item paginationSelectorEl.classList.toggle('display-none', totalItems < 1); - counterSelectorEl.innerHTML = `${totalItems} ${this.itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; + counterSelectorEl.innerHTML = `${totalItems} ${this.displayName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; // Helper function to create a pagination item const createPaginationItem = (page) => { @@ -416,6 +417,11 @@ export class BaseTable { */ initShowMoreButtons(){} + /** + * See function for more details + */ + initCheckboxListeners(){} + /** * Loads rows in the members list, as well as updates pagination around the members list * based on the supplied attributes. @@ -431,7 +437,7 @@ export class BaseTable { let searchParams = this.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); // --------- FETCH DATA - // fetch json of page of domains, given params + // fetch json of page of objects, given params const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null; if (!baseUrlValue) return; @@ -462,6 +468,7 @@ export class BaseTable { }); this.initShowMoreButtons(); + this.initCheckboxListeners(); this.loadModals(data.page, data.total, data.unfiltered_total); diff --git a/src/registrar/assets/src/js/getgov/table-domain-requests.js b/src/registrar/assets/src/js/getgov/table-domain-requests.js index c005ed891..51e4ea12b 100644 --- a/src/registrar/assets/src/js/getgov/table-domain-requests.js +++ b/src/registrar/assets/src/js/getgov/table-domain-requests.js @@ -23,6 +23,7 @@ export class DomainRequestsTable extends BaseTable { constructor() { super('domain-request'); + this.displayName = "domain request"; } getBaseUrl() { diff --git a/src/registrar/assets/src/js/getgov/table-edit-member-domains.js b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js new file mode 100644 index 000000000..95492d46f --- /dev/null +++ b/src/registrar/assets/src/js/getgov/table-edit-member-domains.js @@ -0,0 +1,234 @@ + +import { BaseTable } from './table-base.js'; + +/** + * EditMemberDomainsTable is used for PortfolioMember and PortfolioInvitedMember + * Domain Editing. + * + * This table has additional functionality for tracking and making changes + * to domains assigned to the member/invited member. + */ +export class EditMemberDomainsTable extends BaseTable { + + constructor() { + super('edit-member-domain'); + this.displayName = "domain"; + this.currentSortBy = 'name'; + this.initialDomainAssignments = []; // list of initially assigned domains + this.initialDomainAssignmentsOnlyMember = []; // list of initially assigned domains which are readonly + this.addedDomains = []; // list of domains added to member + this.removedDomains = []; // list of domains removed from member + this.initializeDomainAssignments(); + this.initCancelEditDomainAssignmentButton(); + } + getBaseUrl() { + return document.getElementById("get_member_domains_json_url"); + } + getDataObjects(data) { + return data.domains; + } + /** getDomainAssignmentSearchParams is used to prepare search to populate + * initialDomainAssignments and initialDomainAssignmentsOnlyMember + * + * searches with memberOnly True so that only domains assigned to the member are returned + */ + getDomainAssignmentSearchParams(portfolio) { + let searchParams = new URLSearchParams(); + let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null; + let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null; + let memberOnly = true; + 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); + return searchParams; + } + /** getSearchParams extends base class getSearchParams. + * + * additional searchParam for this table is checkedDomains. This is used to allow + * for backend sorting by domains which are 'checked' in the form. + */ + getSearchParams(page, sortBy, order, searchTerm, status, portfolio) { + let searchParams = super.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); + // Add checkedDomains to searchParams + // Clone the initial domains to avoid mutating them + let checkedDomains = [...this.initialDomainAssignments]; + // Add IDs from addedDomains that are not already in checkedDomains + this.addedDomains.forEach(domain => { + if (!checkedDomains.includes(domain.id)) { + checkedDomains.push(domain.id); + } + }); + // Remove IDs from removedDomains + this.removedDomains.forEach(domain => { + const index = checkedDomains.indexOf(domain.id); + if (index !== -1) { + checkedDomains.splice(index, 1); + } + }); + // Append updated checkedDomain IDs to searchParams + if (checkedDomains.length > 0) { + searchParams.append("checkedDomainIds", checkedDomains.join(",")); + } + return searchParams; + } + addRow(dataObject, tbody, customTableOptions) { + const domain = dataObject; + const row = document.createElement('tr'); + let checked = false; + let disabled = false; + if ( + (this.initialDomainAssignments.includes(domain.id) || + this.addedDomains.map(obj => obj.id).includes(domain.id)) && + !this.removedDomains.map(obj => obj.id).includes(domain.id) + ) { + checked = true; + } + if (this.initialDomainAssignmentsOnlyMember.includes(domain.id)) { + disabled = true; + } + + row.innerHTML = ` + +
+ + +
+ + + ${domain.name} + ${disabled ? 'Domains must have one domain manager. To unassign this member, the domain needs another domain manager.' : ''} + + `; + tbody.appendChild(row); + } + /** + * initializeDomainAssignments searches via ajax on page load for domains assigned to + * member. It populates both initialDomainAssignments and initialDomainAssignmentsOnlyMember. + * It is called once per page load, but not called with subsequent table changes. + */ + initializeDomainAssignments() { + const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null; + if (!baseUrlValue) return; + let searchParams = this.getDomainAssignmentSearchParams(this.portfolioValue); + 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; + } + + let dataObjects = this.getDataObjects(data); + // Map the id attributes of dataObjects to this.initialDomainAssignments + this.initialDomainAssignments = dataObjects.map(obj => obj.id); + this.initialDomainAssignmentsOnlyMember = dataObjects + .filter(obj => obj.member_is_only_manager) + .map(obj => obj.id); + }) + .catch(error => console.error('Error fetching domain assignments:', error)); + } + /** + * Initializes listeners on checkboxes in the table. Checkbox listeners are used + * in this case to track changes to domain assignments in js (addedDomains and removedDomains) + * before changes are saved. + * initCheckboxListeners is called each time table is loaded. + */ + initCheckboxListeners() { + const checkboxes = this.tableWrapper.querySelectorAll('input[type="checkbox"]'); + checkboxes.forEach(checkbox => { + checkbox.addEventListener('change', () => { + const domain = { id: +checkbox.value, name: checkbox.name }; + + if (checkbox.checked) { + this.updateDomainLists(domain, this.removedDomains, this.addedDomains); + } else { + this.updateDomainLists(domain, this.addedDomains, this.removedDomains); + } + }); + }); + } + /** + * Helper function which updates domain lists. When called, if domain is in the fromList, + * it removes it; if domain is not in the toList, it is added to the toList. + * @param {*} domain - object containing the domain id and name + * @param {*} fromList - list of domains + * @param {*} toList - list of domains + */ + updateDomainLists(domain, fromList, toList) { + const index = fromList.findIndex(item => item.id === domain.id && item.name === domain.name); + + if (index > -1) { + fromList.splice(index, 1); // Remove from the `fromList` if it exists + } else { + toList.push(domain); // Add to the `toList` if not already there + } + } + /** + * initializes the Cancel button on the Edit domains page. + * Cancel triggers modal in certain conditions and the initialization for the modal is done + * in this function. + */ + initCancelEditDomainAssignmentButton() { + const cancelEditDomainAssignmentButton = document.getElementById('cancel-edit-domain-assignments'); + if (!cancelEditDomainAssignmentButton) { + console.error("Expected element #cancel-edit-domain-assignments, but it does not exist."); + return; // Exit early if the button doesn't exist + } + + // Find the last breadcrumb link + const lastPageLinkElement = document.querySelector('.usa-breadcrumb__list-item:nth-last-child(2) a'); + const lastPageLink = lastPageLinkElement ? lastPageLinkElement.getAttribute('href') : null; + + const hiddenModalTrigger = document.getElementById("hidden-cancel-edit-domain-assignments-modal-trigger"); + + if (!lastPageLink) { + console.warn("Last breadcrumb link not found or missing href."); + } + if (!hiddenModalTrigger) { + console.warn("Hidden modal trigger not found."); + } + + // Add click event listener + cancelEditDomainAssignmentButton.addEventListener('click', () => { + if (this.addedDomains.length || this.removedDomains.length) { + console.log('Changes detected. Triggering modal...'); + hiddenModalTrigger.click(); + } else if (lastPageLink) { + window.location.href = lastPageLink; // Redirect to the last breadcrumb link + } else { + console.warn("No changes detected, but no valid lastPageLink to navigate to."); + + } + }); + } + +} + +export function initEditMemberDomainsTable() { + document.addEventListener('DOMContentLoaded', function() { + const isEditMemberDomainsPage = document.getElementById("edit-member-domains"); + if (isEditMemberDomainsPage) { + const editMemberDomainsTable = new EditMemberDomainsTable(); + if (editMemberDomainsTable.tableWrapper) { + // Initial load + editMemberDomainsTable.loadTable(1); + } + } + }); +} diff --git a/src/registrar/assets/src/js/getgov/table-member-domains.js b/src/registrar/assets/src/js/getgov/table-member-domains.js index 7d235f6e5..54e9d1212 100644 --- a/src/registrar/assets/src/js/getgov/table-member-domains.js +++ b/src/registrar/assets/src/js/getgov/table-member-domains.js @@ -5,6 +5,7 @@ export class MemberDomainsTable extends BaseTable { constructor() { super('member-domain'); + this.displayName = "domain"; this.currentSortBy = 'name'; } getBaseUrl() { diff --git a/src/registrar/assets/src/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss index ed2d5685b..45f0b5245 100644 --- a/src/registrar/assets/src/sass/_theme/_tables.scss +++ b/src/registrar/assets/src/sass/_theme/_tables.scss @@ -73,11 +73,15 @@ th { } } - td, th, - .usa-tabel th{ + td, th { padding: units(2) units(4) units(2) 0; } + // Hack fix to the overly specific selector above that broke utility class usefulness + .padding-right-105 { + padding-right: .75rem; + } + thead tr:first-child th:first-child { border-top: none; } diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index caf51cc36..66708c571 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -109,6 +109,11 @@ urlpatterns = [ views.PortfolioMemberDomainsView.as_view(), name="member-domains", ), + path( + "member//domains/edit", + views.PortfolioMemberDomainsEditView.as_view(), + name="member-domains-edit", + ), path( "invitedmember/", views.PortfolioInvitedMemberView.as_view(), @@ -129,6 +134,11 @@ urlpatterns = [ views.PortfolioInvitedMemberDomainsView.as_view(), name="invitedmember-domains", ), + path( + "invitedmember//domains/edit", + views.PortfolioInvitedMemberDomainsEditView.as_view(), + name="invitedmember-domains-edit", + ), # path( # "no-organization-members/", # views.PortfolioNoMembersView.as_view(), diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index c1547ad88..9f5d0162f 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -99,7 +99,7 @@ def portfolio_permissions(request): def is_widescreen_mode(request): - widescreen_paths = [] + widescreen_paths = [] # If this list is meant to include specific paths, populate it. portfolio_widescreen_paths = [ "/domains/", "/requests/", @@ -108,10 +108,21 @@ def is_widescreen_mode(request): "/no-organization-domains/", "/domain-request/", ] + # widescreen_paths can be a bear as it trickles down sub-urls. exclude_paths gives us a way out. + exclude_paths = [ + "/domains/edit", + ] + + # Check if the current path matches a widescreen path or the root path. is_widescreen = any(path in request.path for path in widescreen_paths) or request.path == "/" - is_portfolio_widescreen = bool( + + # Check if the user is an organization user and the path matches portfolio paths. + is_portfolio_widescreen = ( hasattr(request.user, "is_org_user") and request.user.is_org_user(request) and any(path in request.path for path in portfolio_widescreen_paths) + and not any(exclude_path in request.path for exclude_path in exclude_paths) ) + + # Return a dictionary with the widescreen mode status. return {"is_widescreen_mode": is_widescreen or is_portfolio_widescreen} diff --git a/src/registrar/templates/401.html b/src/registrar/templates/401.html index 20ca0420e..d7c7f83ae 100644 --- a/src/registrar/templates/401.html +++ b/src/registrar/templates/401.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Unauthorized | " %}{% endblock %} {% block content %} -
+

diff --git a/src/registrar/templates/403.html b/src/registrar/templates/403.html index ef910a191..999d5f98e 100644 --- a/src/registrar/templates/403.html +++ b/src/registrar/templates/403.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Forbidden | " %}{% endblock %} {% block content %} -
+

diff --git a/src/registrar/templates/404.html b/src/registrar/templates/404.html index 024c2803b..471575558 100644 --- a/src/registrar/templates/404.html +++ b/src/registrar/templates/404.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Page not found | " %}{% endblock %} {% block content %} -
+

diff --git a/src/registrar/templates/500.html b/src/registrar/templates/500.html index 95c17e069..a0663816b 100644 --- a/src/registrar/templates/500.html +++ b/src/registrar/templates/500.html @@ -5,7 +5,7 @@ {% block title %}{% translate "Server error | " %}{% endblock %} {% block content %} -
+

diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index b00c57b5c..b1c3775df 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -5,7 +5,7 @@ {% block title %} Home | {% endblock %} {% block content %} -
+
{% if user.is_authenticated %} {# the entire logged in page goes here #} diff --git a/src/registrar/templates/includes/footer.html b/src/registrar/templates/includes/footer.html index c8d237821..74ef3dc50 100644 --- a/src/registrar/templates/includes/footer.html +++ b/src/registrar/templates/includes/footer.html @@ -3,7 +3,7 @@