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 @@