diff --git a/.gitignore b/.gitignore index f2d82f599..3092682c2 100644 --- a/.gitignore +++ b/.gitignore @@ -171,6 +171,9 @@ node_modules # Vim *.swp +# VS Code +.vscode + # Compliance/trestle related docs/compliance/.trestle/cache 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/admin.py b/src/registrar/admin.py index 0cab01d31..042666619 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1,12 +1,14 @@ from datetime import date import logging import copy +from typing import Optional from django import forms from django.db.models import Value, CharField, Q from django.db.models.functions import Concat, Coalesce from django.http import HttpResponseRedirect from registrar.models.federal_agency import FederalAgency from registrar.utility.admin_helpers import ( + AutocompleteSelectWithPlaceholder, get_action_needed_reason_default_email, get_rejection_reason_default_email, get_field_links_as_list, @@ -236,6 +238,14 @@ class DomainRequestAdminForm(forms.ModelForm): "current_websites": NoAutocompleteFilteredSelectMultiple("current_websites", False), "alternative_domains": NoAutocompleteFilteredSelectMultiple("alternative_domains", False), "other_contacts": NoAutocompleteFilteredSelectMultiple("other_contacts", False), + "portfolio": AutocompleteSelectWithPlaceholder( + DomainRequest._meta.get_field("portfolio"), admin.site, attrs={"data-placeholder": "---------"} + ), + "sub_organization": AutocompleteSelectWithPlaceholder( + DomainRequest._meta.get_field("sub_organization"), + admin.site, + attrs={"data-placeholder": "---------", "ajax-url": "get-suborganization-list-json"}, + ), } labels = { "action_needed_reason_email": "Email", @@ -1816,6 +1826,70 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): custom_election_board.admin_order_field = "is_election_board" # type: ignore custom_election_board.short_description = "Election office" # type: ignore + # Define methods to display fields from the related portfolio + def portfolio_senior_official(self, obj) -> Optional[SeniorOfficial]: + return obj.portfolio.senior_official if obj.portfolio and obj.portfolio.senior_official else None + + portfolio_senior_official.short_description = "Senior official" # type: ignore + + def portfolio_organization_type(self, obj): + return ( + DomainRequest.OrganizationChoices.get_org_label(obj.portfolio.organization_type) + if obj.portfolio and obj.portfolio.organization_type + else "-" + ) + + portfolio_organization_type.short_description = "Organization type" # type: ignore + + def portfolio_federal_type(self, obj): + return ( + BranchChoices.get_branch_label(obj.portfolio.federal_type) + if obj.portfolio and obj.portfolio.federal_type + else "-" + ) + + portfolio_federal_type.short_description = "Federal type" # type: ignore + + def portfolio_organization_name(self, obj): + return obj.portfolio.organization_name if obj.portfolio else "" + + portfolio_organization_name.short_description = "Organization name" # type: ignore + + def portfolio_federal_agency(self, obj): + return obj.portfolio.federal_agency if obj.portfolio else "" + + portfolio_federal_agency.short_description = "Federal agency" # type: ignore + + def portfolio_state_territory(self, obj): + return obj.portfolio.state_territory if obj.portfolio else "" + + portfolio_state_territory.short_description = "State, territory, or military post" # type: ignore + + def portfolio_address_line1(self, obj): + return obj.portfolio.address_line1 if obj.portfolio else "" + + portfolio_address_line1.short_description = "Address line 1" # type: ignore + + def portfolio_address_line2(self, obj): + return obj.portfolio.address_line2 if obj.portfolio else "" + + portfolio_address_line2.short_description = "Address line 2" # type: ignore + + def portfolio_city(self, obj): + return obj.portfolio.city if obj.portfolio else "" + + portfolio_city.short_description = "City" # type: ignore + + def portfolio_zipcode(self, obj): + return obj.portfolio.zipcode if obj.portfolio else "" + + portfolio_zipcode.short_description = "Zip code" # type: ignore + + def portfolio_urbanization(self, obj): + return obj.portfolio.urbanization if obj.portfolio else "" + + portfolio_urbanization.short_description = "Urbanization" # type: ignore + # This is just a placeholder. This field will be populated in the detail_table_fieldset view. # This is not a field that exists on the model. def status_history(self, obj): @@ -1847,30 +1921,38 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): None, { "fields": [ - "portfolio", - "sub_organization", - "requested_suborganization", - "suborganization_city", - "suborganization_state_territory", "status_history", "status", "rejection_reason", "rejection_reason_email", "action_needed_reason", "action_needed_reason_email", - "investigator", - "creator", "approved_domain", + "investigator", "notes", ] }, ), + ( + "Requested by", + { + "fields": [ + "portfolio", + "sub_organization", + "requested_suborganization", + "suborganization_city", + "suborganization_state_territory", + "creator", + ] + }, + ), (".gov domain", {"fields": ["requested_domain", "alternative_domains"]}), ( "Contacts", { "fields": [ "senior_official", + "portfolio_senior_official", "other_contacts", "no_other_contacts_rationale", "cisa_representative_first_name", @@ -1927,10 +2009,55 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): ], }, ), + # the below three sections are for portfolio fields + ( + "Type of organization", + { + "fields": [ + "portfolio_organization_type", + "portfolio_federal_type", + ] + }, + ), + ( + "Organization name and mailing address", + { + "fields": [ + "portfolio_organization_name", + "portfolio_federal_agency", + ] + }, + ), + ( + "Show details", + { + "classes": ["collapse--dgfieldset"], + "description": "Extends organization name and mailing address", + "fields": [ + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", + ], + }, + ), ] # Readonly fields for analysts and superusers readonly_fields = ( + "portfolio_senior_official", + "portfolio_organization_type", + "portfolio_federal_type", + "portfolio_organization_name", + "portfolio_federal_agency", + "portfolio_state_territory", + "portfolio_address_line1", + "portfolio_address_line2", + "portfolio_city", + "portfolio_zipcode", + "portfolio_urbanization", "other_contacts", "current_websites", "alternative_domains", @@ -1979,10 +2106,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): def get_fieldsets(self, request, obj=None): fieldsets = super().get_fieldsets(request, obj) - # Hide certain suborg fields behind the organization feature flag + # Hide certain portfolio and suborg fields behind the organization requests flag # if it is not enabled - if not flag_is_active_for_user(request.user, "organization_feature"): + if not flag_is_active_for_user(request.user, "organization_requests"): excluded_fields = [ + "portfolio", + "sub_organization", "requested_suborganization", "suborganization_city", "suborganization_state_territory", diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index a5c55acb1..9fd15b9f9 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -86,6 +86,506 @@ function handleSuborganizationFields( portfolioDropdown.on("change", toggleSuborganizationFields); } + +/** + * This function handles the portfolio selection as well as display of + * portfolio-related fields in the DomainRequest Form. + * + * IMPORTANT NOTE: The logic in this method is paired dynamicPortfolioFields +*/ +function handlePortfolioSelection() { + // These dropdown are select2 fields so they must be interacted with via jquery + const portfolioDropdown = django.jQuery("#id_portfolio"); + const suborganizationDropdown = django.jQuery("#id_sub_organization"); + const suborganizationField = document.querySelector(".field-sub_organization"); + const requestedSuborganizationField = document.querySelector(".field-requested_suborganization"); + const suborganizationCity = document.querySelector(".field-suborganization_city"); + const suborganizationStateTerritory = document.querySelector(".field-suborganization_state_territory"); + const seniorOfficialField = document.querySelector(".field-senior_official"); + const otherEmployeesField = document.querySelector(".field-other_contacts"); + const noOtherContactsRationaleField = document.querySelector(".field-no_other_contacts_rationale"); + const cisaRepresentativeFirstNameField = document.querySelector(".field-cisa_representative_first_name"); + const cisaRepresentativeLastNameField = document.querySelector(".field-cisa_representative_last_name"); + const cisaRepresentativeEmailField = document.querySelector(".field-cisa_representative_email"); + const orgTypeFieldSet = document.querySelector(".field-is_election_board").parentElement; + const orgTypeFieldSetDetails = orgTypeFieldSet.nextElementSibling; + const orgNameFieldSet = document.querySelector(".field-organization_name").parentElement; + const orgNameFieldSetDetails = orgNameFieldSet.nextElementSibling; + const portfolioSeniorOfficialField = document.querySelector(".field-portfolio_senior_official"); + const portfolioSeniorOfficial = portfolioSeniorOfficialField.querySelector(".readonly"); + const portfolioSeniorOfficialAddress = portfolioSeniorOfficialField.querySelector(".dja-address-contact-list"); + const portfolioOrgTypeFieldSet = document.querySelector(".field-portfolio_organization_type").parentElement; + const portfolioOrgType = document.querySelector(".field-portfolio_organization_type .readonly"); + const portfolioFederalTypeField = document.querySelector(".field-portfolio_federal_type"); + const portfolioFederalType = portfolioFederalTypeField.querySelector(".readonly"); + const portfolioOrgNameField = document.querySelector(".field-portfolio_organization_name") + const portfolioOrgName = portfolioOrgNameField.querySelector(".readonly"); + const portfolioOrgNameFieldSet = portfolioOrgNameField.parentElement; + const portfolioOrgNameFieldSetDetails = portfolioOrgNameFieldSet.nextElementSibling; + const portfolioFederalAgencyField = document.querySelector(".field-portfolio_federal_agency"); + const portfolioFederalAgency = portfolioFederalAgencyField.querySelector(".readonly"); + const portfolioStateTerritory = document.querySelector(".field-portfolio_state_territory .readonly"); + const portfolioAddressLine1 = document.querySelector(".field-portfolio_address_line1 .readonly"); + const portfolioAddressLine2 = document.querySelector(".field-portfolio_address_line2 .readonly"); + const portfolioCity = document.querySelector(".field-portfolio_city .readonly"); + const portfolioZipcode = document.querySelector(".field-portfolio_zipcode .readonly"); + const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization"); + const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly"); + const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; + let isPageLoading = true; + + /** + * Fetches portfolio data by ID using an AJAX call. + * + * @param {number|string} portfolio_id - The ID of the portfolio to retrieve. + * @returns {Promise} - A promise that resolves to the portfolio data object if successful, + * or null if there was an error. + * + * This function performs an asynchronous fetch request to retrieve portfolio data. + * If the request is successful, it returns the portfolio data as an object. + * If an error occurs during the request or the data contains an error, it logs the error + * to the console and returns null. + */ + function getPortfolio(portfolio_id) { + return fetch(`${portfolioJsonUrl}?id=${portfolio_id}`) + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + return null; + } else { + return data; + } + }) + .catch(error => { + console.error("Error retrieving portfolio", error); + return null; + }); + } + + /** + * Updates various UI elements with the data from a given portfolio object. + * + * @param {Object} portfolio - The portfolio data object containing values to populate in the UI. + * + * This function updates multiple fields in the UI to reflect data in the `portfolio` object: + * - Clears and replaces selections in the `suborganizationDropdown` with values from `portfolio.suborganizations`. + * - Calls `updatePortfolioSeniorOfficial` to set the senior official information. + * - Sets the portfolio organization type, federal type, name, federal agency, and other address-related fields. + * + * The function expects that elements like `portfolioOrgType`, `portfolioFederalAgency`, etc., + * are already defined and accessible in the global scope. + */ + function updatePortfolioFieldsData(portfolio) { + // replace selections in suborganizationDropdown with + // values in portfolio.suborganizations + suborganizationDropdown.empty(); + // update portfolio senior official + updatePortfolioSeniorOfficial(portfolio.senior_official); + // update portfolio organization type + portfolioOrgType.innerText = portfolio.organization_type; + // update portfolio federal type + portfolioFederalType.innerText = portfolio.federal_type + // update portfolio organization name + portfolioOrgName.innerText = portfolio.organization_name; + // update portfolio federal agency + portfolioFederalAgency.innerText = portfolio.federal_agency ? portfolio.federal_agency.agency : ''; + // update portfolio state + portfolioStateTerritory.innerText = portfolio.state_territory; + // update portfolio address line 1 + portfolioAddressLine1.innerText = portfolio.address_line1; + // update portfolio address line 2 + portfolioAddressLine2.innerText = portfolio.address_line2; + // update portfolio city + portfolioCity.innerText = portfolio.city; + // update portfolio zip code + portfolioZipcode.innerText = portfolio.zipcode + // update portfolio urbanization + portfolioUrbanization.innerText = portfolio.urbanization; + } + + /** + * Updates the UI to display the senior official information from a given object. + * + * @param {Object} senior_official - The senior official's data object, containing details like + * first name, last name, and ID. If `senior_official` is null, displays a default message. + * + * This function: + * - Displays the senior official's name as a link (if available) in the `portfolioSeniorOfficial` element. + * - If a senior official exists, it sets `portfolioSeniorOfficialAddress` to show the official's contact info + * and displays it by calling `updateSeniorOfficialContactInfo`. + * - If no senior official is provided, it hides `portfolioSeniorOfficialAddress` and shows a "No senior official found." message. + * + * Dependencies: + * - Expects the `portfolioSeniorOfficial` and `portfolioSeniorOfficialAddress` elements to be available globally. + * - Uses `showElement` and `hideElement` for visibility control. + */ + function updatePortfolioSeniorOfficial(senior_official) { + if (senior_official) { + let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(' '); + let seniorOfficialLink = `${seniorOfficialName}` + portfolioSeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + updateSeniorOfficialContactInfo(portfolioSeniorOfficialAddress, senior_official); + showElement(portfolioSeniorOfficialAddress); + } else { + portfolioSeniorOfficial.innerText = "No senior official found."; + hideElement(portfolioSeniorOfficialAddress); + } + } + + /** + * Populates and displays contact information for a senior official within a specified address field element. + * + * @param {HTMLElement} addressField - The DOM element containing contact info fields for the senior official. + * @param {Object} senior_official - The senior official's data object, containing properties like title, email, and phone. + * + * This function: + * - Sets the `title`, `email`, and `phone` fields in `addressField` to display the senior official's data. + * - Updates the `titleSpan` with the official's title, or "None" if unavailable. + * - Updates the `emailSpan` with the official's email, or "None" if unavailable. + * - If an email is provided, populates `hiddenInput` with the email for copying and shows the `copyButton`. + * - If no email is provided, hides the `copyButton`. + * - Updates the `phoneSpan` with the official's phone number, or "None" if unavailable. + * + * Dependencies: + * - Uses `showElement` and `hideElement` to control visibility of the `copyButton`. + * - Expects `addressField` to have specific classes (.contact_info_title, .contact_info_email, etc.) for query selectors to work. + */ + function updateSeniorOfficialContactInfo(addressField, senior_official) { + const titleSpan = addressField.querySelector(".contact_info_title"); + const emailSpan = addressField.querySelector(".contact_info_email"); + const phoneSpan = addressField.querySelector(".contact_info_phone"); + const hiddenInput = addressField.querySelector("input"); + const copyButton = addressField.querySelector(".admin-icon-group"); + if (titleSpan) { + titleSpan.textContent = senior_official.title || "None"; + }; + if (emailSpan) { + emailSpan.textContent = senior_official.email || "None"; + if (senior_official.email) { + hiddenInput.value = senior_official.email; + showElement(copyButton); + }else { + hideElement(copyButton); + } + } + if (phoneSpan) { + phoneSpan.textContent = senior_official.phone || "None"; + }; + } + + /** + * Dynamically updates the visibility of certain portfolio fields based on specific conditions. + * + * This function adjusts the display of fields within the portfolio UI based on: + * - The presence of a senior official's contact information. + * - The selected state or territory, affecting the visibility of the urbanization field. + * - The organization type (Federal vs. non-Federal), toggling the visibility of related fields. + * + * Functionality: + * 1. **Senior Official Contact Info Display**: + * - If `portfolioSeniorOfficial` contains "No additional contact information found", + * hides `portfolioSeniorOfficialAddress`; otherwise, shows it. + * + * 2. **Urbanization Field Display**: + * - Displays `portfolioUrbanizationField` only when the `portfolioStateTerritory` value is "PR" (Puerto Rico). + * + * 3. **Federal Organization Type Display**: + * - If `portfolioOrgType` is "Federal", hides `portfolioOrgNameField` and shows both `portfolioFederalAgencyField` + * and `portfolioFederalTypeField`. + * - If not Federal, shows `portfolioOrgNameField` and hides `portfolioFederalAgencyField` and `portfolioFederalTypeField`. + * - Certain text fields (Organization Type, Organization Name, Federal Type, Federal Agency) updated to links + * to edit the portfolio + * + * Dependencies: + * - Expects specific elements to be defined globally (`portfolioSeniorOfficial`, `portfolioUrbanizationField`, etc.). + * - Uses `showElement` and `hideElement` functions to control element visibility. + */ + function updatePortfolioFieldsDataDynamicDisplay() { + + // Handle visibility of senior official's contact information + if (portfolioSeniorOfficial.innerText.includes("No senior official found.")) { + hideElement(portfolioSeniorOfficialAddress); + } else { + showElement(portfolioSeniorOfficialAddress); + } + + // Handle visibility of urbanization field based on state/territory value + let portfolioStateTerritoryValue = portfolioStateTerritory.innerText; + if (portfolioStateTerritoryValue === "PR") { + showElement(portfolioUrbanizationField); + } else { + hideElement(portfolioUrbanizationField); + } + + // Handle visibility of fields based on organization type (Federal vs. others) + if (portfolioOrgType.innerText === "Federal") { + hideElement(portfolioOrgNameField); + showElement(portfolioFederalAgencyField); + showElement(portfolioFederalTypeField); + } else { + showElement(portfolioOrgNameField); + hideElement(portfolioFederalAgencyField); + hideElement(portfolioFederalTypeField); + } + + // Modify the display of certain fields to convert them from text to links + // to edit the portfolio + let portfolio_id = portfolioDropdown.val(); + let portfolioEditUrl = `/admin/registrar/portfolio/${portfolio_id}/change/`; + let portfolioOrgTypeValue = portfolioOrgType.innerText; + portfolioOrgType.innerHTML = `${portfolioOrgTypeValue}`; + let portfolioOrgNameValue = portfolioOrgName.innerText; + portfolioOrgName.innerHTML = `${portfolioOrgNameValue}`; + let portfolioFederalAgencyValue = portfolioFederalAgency.innerText; + portfolioFederalAgency.innerHTML = `${portfolioFederalAgencyValue}`; + let portfolioFederalTypeValue = portfolioFederalType.innerText; + if (portfolioFederalTypeValue !== '-') + portfolioFederalType.innerHTML = `${portfolioFederalTypeValue}`; + + } + + /** + * Asynchronously updates portfolio fields in the UI based on the selected portfolio. + * + * This function first checks if the page is loading or if a portfolio selection is available + * in the `portfolioDropdown`. If a portfolio is selected, it retrieves the portfolio data, + * then updates the UI fields to display relevant data. If no portfolio is selected, it simply + * refreshes the UI field display without new data. The `isPageLoading` flag prevents + * updates during page load. + * + * Workflow: + * 1. **Check Page Loading**: + * - If `isPageLoading` is `true`, set it to `false` and exit to prevent redundant updates. + * - If `isPageLoading` is `false`, proceed with portfolio field updates. + * + * 2. **Portfolio Selection**: + * - If a portfolio is selected (`portfolioDropdown.val()`), fetch the portfolio data. + * - Once data is fetched, run three update functions: + * - `updatePortfolioFieldsData`: Populates specific portfolio-related fields. + * - `updatePortfolioFieldsDisplay`: Handles the visibility of general portfolio fields. + * - `updatePortfolioFieldsDataDynamicDisplay`: Manages conditional display based on portfolio data. + * - If no portfolio is selected, only refreshes the field display using `updatePortfolioFieldsDisplay`. + * + * Dependencies: + * - Expects global elements (`portfolioDropdown`, etc.) and `isPageLoading` flag to be defined. + * - Assumes `getPortfolio`, `updatePortfolioFieldsData`, `updatePortfolioFieldsDisplay`, and `updatePortfolioFieldsDataDynamicDisplay` are available as functions. + */ + async function updatePortfolioFields() { + if (!isPageLoading) { + if (portfolioDropdown.val()) { + getPortfolio(portfolioDropdown.val()).then((portfolio) => { + updatePortfolioFieldsData(portfolio); + updatePortfolioFieldsDisplay(); + updatePortfolioFieldsDataDynamicDisplay(); + }); + } else { + updatePortfolioFieldsDisplay(); + } + } else { + isPageLoading = false; + } + } + + /** + * Updates the Suborganization Dropdown with new data based on the provided portfolio ID. + * + * This function uses the Select2 jQuery plugin to update the dropdown by fetching suborganization + * data relevant to the selected portfolio. Upon invocation, it checks if Select2 is already initialized + * on `suborganizationDropdown` and destroys the existing instance to avoid duplication. + * It then reinitializes Select2 with customized options for an AJAX request, allowing the user to search + * and select suborganizations dynamically, with results filtered based on `portfolio_id`. + * + * Key workflow: + * 1. **Document Ready**: Ensures that the function runs only once the DOM is fully loaded. + * 2. **Check and Reinitialize Select2**: + * - If Select2 is already initialized, it’s destroyed to refresh with new options. + * - Select2 is reinitialized with AJAX settings for dynamic data fetching. + * 3. **AJAX Options**: + * - **Data Function**: Prepares the query by capturing the user's search term (`params.term`) + * and the provided `portfolio_id` to filter relevant suborganizations. + * - **Data Type**: Ensures responses are returned as JSON. + * - **Delay**: Introduces a 250ms delay to prevent excessive requests on fast typing. + * - **Cache**: Enables caching to improve performance. + * 4. **Theme and Placeholder**: + * - Sets the dropdown theme to ‘admin-autocomplete’ for consistent styling. + * - Allows clearing of the dropdown and displays a placeholder as defined in the HTML. + * + * Dependencies: + * - Requires `suborganizationDropdown` element, the jQuery library, and the Select2 plugin. + * - `portfolio_id` is passed to filter results relevant to a specific portfolio. + */ + function updateSubOrganizationDropdown(portfolio_id) { + django.jQuery(document).ready(function() { + if (suborganizationDropdown.data('select2')) { + suborganizationDropdown.select2('destroy'); + } + // Reinitialize Select2 with the updated URL + suborganizationDropdown.select2({ + ajax: { + data: function (params) { + var query = { + search: params.term, + portfolio_id: portfolio_id + } + return query; + }, + dataType: 'json', + delay: 250, + cache: true + }, + theme: 'admin-autocomplete', + allowClear: true, + placeholder: suborganizationDropdown.attr('data-placeholder') + }); + }); + } + + /** + * Updates the display of portfolio-related fields based on whether a portfolio is selected. + * + * This function controls the visibility of specific fields by showing or hiding them + * depending on the presence of a selected portfolio ID in the dropdown. When a portfolio + * is selected, certain fields are shown (like suborganizations and portfolio-related fields), + * while others are hidden (like senior official and other employee-related fields). + * + * Workflow: + * 1. **Retrieve Portfolio ID**: + * - Fetches the selected value from `portfolioDropdown` to check if a portfolio is selected. + * + * 2. **Display Fields for Selected Portfolio**: + * - If a `portfolio_id` exists, it updates the `suborganizationDropdown` for the specific portfolio. + * - Shows or hides various fields to display only relevant portfolio information: + * - Shows `suborganizationField`, `portfolioSeniorOfficialField`, and fields related to the portfolio organization. + * - Hides fields that are not applicable when a portfolio is selected, such as `seniorOfficialField` and `otherEmployeesField`. + * + * 3. **Display Fields for No Portfolio Selected**: + * - If no portfolio is selected (i.e., `portfolio_id` is falsy), it reverses the visibility: + * - Hides `suborganizationField` and other portfolio-specific fields. + * - Shows fields that are applicable when no portfolio is selected, such as the `seniorOfficialField`. + * + * Dependencies: + * - `portfolioDropdown` is assumed to be a dropdown element containing portfolio IDs. + * - `showElement` and `hideElement` utility functions are used to control element visibility. + * - Various global field elements (e.g., `suborganizationField`, `seniorOfficialField`, `portfolioOrgTypeFieldSet`) are used. + */ + function updatePortfolioFieldsDisplay() { + // Retrieve the selected portfolio ID + let portfolio_id = portfolioDropdown.val(); + + if (portfolio_id) { + // A portfolio is selected - update suborganization dropdown and show/hide relevant fields + + // Update suborganization dropdown for the selected portfolio + updateSubOrganizationDropdown(portfolio_id); + + // Show fields relevant to a selected portfolio + showElement(suborganizationField); + hideElement(seniorOfficialField); + showElement(portfolioSeniorOfficialField); + + // Hide fields not applicable when a portfolio is selected + hideElement(otherEmployeesField); + hideElement(noOtherContactsRationaleField); + hideElement(cisaRepresentativeFirstNameField); + hideElement(cisaRepresentativeLastNameField); + hideElement(cisaRepresentativeEmailField); + hideElement(orgTypeFieldSet); + hideElement(orgTypeFieldSetDetails); + hideElement(orgNameFieldSet); + hideElement(orgNameFieldSetDetails); + + // Show portfolio-specific fields + showElement(portfolioOrgTypeFieldSet); + showElement(portfolioOrgNameFieldSet); + showElement(portfolioOrgNameFieldSetDetails); + } else { + // No portfolio is selected - reverse visibility of fields + + // Hide suborganization field as no portfolio is selected + hideElement(suborganizationField); + + // Show fields that are relevant when no portfolio is selected + showElement(seniorOfficialField); + hideElement(portfolioSeniorOfficialField); + showElement(otherEmployeesField); + showElement(noOtherContactsRationaleField); + showElement(cisaRepresentativeFirstNameField); + showElement(cisaRepresentativeLastNameField); + showElement(cisaRepresentativeEmailField); + + // Show organization type and name fields + showElement(orgTypeFieldSet); + showElement(orgTypeFieldSetDetails); + showElement(orgNameFieldSet); + showElement(orgNameFieldSetDetails); + + // Hide portfolio-specific fields that aren’t applicable + hideElement(portfolioOrgTypeFieldSet); + hideElement(portfolioOrgNameFieldSet); + hideElement(portfolioOrgNameFieldSetDetails); + } + + updateSuborganizationFieldsDisplay(); + + } + + /** + * Updates the visibility of suborganization-related fields based on the selected value in the suborganization dropdown. + * + * If a suborganization is selected: + * - Hides the fields related to requesting a new suborganization (`requestedSuborganizationField`). + * - Hides the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields for the suborganization. + * + * If no suborganization is selected: + * - Shows the fields for requesting a new suborganization (`requestedSuborganizationField`). + * - Displays the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields. + * + * This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested. + */ + function updateSuborganizationFieldsDisplay() { + let portfolio_id = portfolioDropdown.val(); + let suborganization_id = suborganizationDropdown.val(); + + if (portfolio_id && !suborganization_id) { + // Show suborganization request fields + showElement(requestedSuborganizationField); + showElement(suborganizationCity); + showElement(suborganizationStateTerritory); + } else { + // Hide suborganization request fields if suborganization is selected + hideElement(requestedSuborganizationField); + hideElement(suborganizationCity); + hideElement(suborganizationStateTerritory); + } + } + + /** + * Initializes necessary data and display configurations for the portfolio fields. + */ + function initializePortfolioSettings() { + // Update the visibility of portfolio-related fields based on current dropdown selection. + updatePortfolioFieldsDisplay(); + + // Dynamically adjust the display of certain fields based on the selected portfolio's characteristics. + updatePortfolioFieldsDataDynamicDisplay(); + } + + /** + * Sets event listeners for key UI elements. + */ + function setEventListeners() { + // When the `portfolioDropdown` selection changes, refresh the displayed portfolio fields. + portfolioDropdown.on("change", updatePortfolioFields); + // When the 'suborganizationDropdown' selection changes + suborganizationDropdown.on("change", updateSuborganizationFieldsDisplay); + } + + // Run initial setup functions + initializePortfolioSettings(); + setEventListeners(); +} + // <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> // Initialization code. @@ -797,6 +1297,63 @@ document.addEventListener('DOMContentLoaded', function() { customEmail.loadRejectedEmail() }); +/** An IIFE that hides and shows approved domain select2 row in domain request + * conditionally based on the Status field selection. If Approved, show. If not Approved, + * don't show. + */ +document.addEventListener('DOMContentLoaded', function() { + const domainRequestForm = document.getElementById("domainrequest_form"); + if (!domainRequestForm) { + return; + } + + const statusToCheck = "approved"; + const statusSelect = document.getElementById("id_status"); + const sessionVariableName = "showApprovedDomain"; + let approvedDomainFormGroup = document.querySelector(".field-approved_domain"); + + function updateFormGroupVisibility(showFormGroups) { + if (showFormGroups) { + showElement(approvedDomainFormGroup); + }else { + hideElement(approvedDomainFormGroup); + } + } + + // Handle showing/hiding the related fields on page load. + function initializeFormGroups() { + let isStatus = statusSelect.value == statusToCheck; + + // Initial handling of these groups. + updateFormGroupVisibility(isStatus); + + // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage + statusSelect.addEventListener('change', () => { + // Show the approved if the status is what we expect. + isStatus = statusSelect.value == statusToCheck; + updateFormGroupVisibility(isStatus); + addOrRemoveSessionBoolean(sessionVariableName, isStatus); + }); + + // Listen to Back/Forward button navigation and handle approvedDomainFormGroup display based on session storage + // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the + // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide + // accurately for this edge case, we use cache and test for the back/forward navigation. + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (entry.type === "back_forward") { + let showTextAreaFormGroup = sessionStorage.getItem(sessionVariableName) !== null; + updateFormGroupVisibility(showTextAreaFormGroup); + } + }); + }); + observer.observe({ type: "navigation" }); + } + + initializeFormGroups(); + +}); + /** An IIFE for copy summary button (appears in DomainRegistry models) */ @@ -844,10 +1401,10 @@ document.addEventListener('DOMContentLoaded', function() { if (contacts) { contacts.forEach(contact => { // Check if the
element is not empty - const name = contact.querySelector('a#contact_info_name')?.innerText; - const title = contact.querySelector('span#contact_info_title')?.innerText; - const email = contact.querySelector('span#contact_info_email')?.innerText; - const phone = contact.querySelector('span#contact_info_phone')?.innerText; + const name = contact.querySelector('a.contact_info_name')?.innerText; + const title = contact.querySelector('span.contact_info_title')?.innerText; + const email = contact.querySelector('span.contact_info_email')?.innerText; + const phone = contact.querySelector('span.contact_info_phone')?.innerText; const url = nameToUrlMap[name] || '#'; // Format the contact information const listItem = document.createElement('li'); @@ -898,9 +1455,9 @@ document.addEventListener('DOMContentLoaded', function() { const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); const seniorOfficialElement = document.getElementById('id_senior_official'); const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text; - const seniorOfficialTitle = extractTextById('contact_info_title', seniorOfficialDiv); - const seniorOfficialEmail = extractTextById('contact_info_email', seniorOfficialDiv); - const seniorOfficialPhone = extractTextById('contact_info_phone', seniorOfficialDiv); + const seniorOfficialTitle = seniorOfficialDiv.querySelector('.contact_info_title'); + const seniorOfficialEmail = seniorOfficialDiv.querySelector('.contact_info_email'); + const seniorOfficialPhone = seniorOfficialDiv.querySelector('.contact_info_phone'); let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`; const html_summary = `Recommendation:
` + @@ -958,6 +1515,7 @@ document.addEventListener('DOMContentLoaded', function() { /** An IIFE for dynamically changing some fields on the portfolio admin model + * IMPORTANT NOTE: The logic in this IIFE is paired handlePortfolioSelection */ (function dynamicPortfolioFields(){ @@ -1184,9 +1742,9 @@ document.addEventListener('DOMContentLoaded', function() { function updateContactInfo(data) { if (!contactList) return; - const titleSpan = contactList.querySelector("#contact_info_title"); - const emailSpan = contactList.querySelector("#contact_info_email"); - const phoneSpan = contactList.querySelector("#contact_info_phone"); + const titleSpan = contactList.querySelector(".contact_info_title"); + const emailSpan = contactList.querySelector(".contact_info_email"); + const phoneSpan = contactList.querySelector(".contact_info_phone"); if (titleSpan) { titleSpan.textContent = data.title || "None"; @@ -1218,7 +1776,7 @@ document.addEventListener('DOMContentLoaded', function() { (function dynamicDomainRequestFields(){ const domainRequestPage = document.getElementById("domainrequest_form"); if (domainRequestPage) { - handleSuborganizationFields(); + handlePortfolioSelection(); } })(); diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index f204092be..f29ab7f47 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -86,6 +86,132 @@ function makeVisible(el) { el.style.visibility = "visible"; } +/** + * Creates and adds a modal dialog to the DOM with customizable attributes and content. + * + * @param {string} id - A unique identifier for the modal, appended to the action for uniqueness. + * @param {string} ariaLabelledby - The ID of the element that labels the modal, for accessibility. + * @param {string} ariaDescribedby - The ID of the element that describes the modal, for accessibility. + * @param {string} modalHeading - The heading text displayed at the top of the modal. + * @param {string} modalDescription - The main descriptive text displayed within the modal. + * @param {string} modalSubmit - The HTML content for the submit button, allowing customization. + * @param {HTMLElement} wrapper_element - Optional. The element to which the modal is appended. If not provided, defaults to `document.body`. + * @param {boolean} forceAction - Optional. If true, adds a `data-force-action` attribute to the modal for additional control. + * + * The modal includes a heading, description, submit button, and a cancel button, along with a close button. + * The `data-close-modal` attribute is added to cancel and close buttons to enable closing functionality. + */ +function addModal(id, ariaLabelledby, ariaDescribedby, modalHeading, modalDescription, modalSubmit, wrapper_element, forceAction) { + + const modal = document.createElement('div'); + modal.setAttribute('class', 'usa-modal'); + modal.setAttribute('id', id); + modal.setAttribute('aria-labelledby', ariaLabelledby); + modal.setAttribute('aria-describedby', ariaDescribedby); + if (forceAction) + modal.setAttribute('data-force-action', ''); + + modal.innerHTML = ` +
+
+

+ ${modalHeading} +

+
+

+ ${modalDescription} +

+
+ +
+ +
+ ` + if (wrapper_element) { + wrapper_element.appendChild(modal); + } else { + document.body.appendChild(modal); + } +} + +/** + * Helper function that creates a dynamic accordion navigation + * @param {string} action - The action type or identifier used to create a unique DOM IDs. + * @param {string} unique_id - An ID that when combined with action makes a unique identifier + * @param {string} modal_button_text - The action button's text + * @param {string} screen_reader_text - A screen reader helper + */ +function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text) { + + const generateModalButton = (mobileOnly = false) => ` + + ${mobileOnly ? `` : ''} + ${modal_button_text} + ${screen_reader_text} + + `; + + // Main kebab structure + const kebab = ` + ${generateModalButton(true)} + +
+
+ +
+ +
+ `; + + return kebab; +} + + /** * Toggles expand_more / expand_more svgs in buttons or anchors * @param {Element} element - DOM element @@ -104,9 +230,9 @@ function toggleCaret(element) { } /** - * Helper function that scrolls to an element + * Helper function that scrolls to an element, identified by a class or an id. * @param {string} attributeName - The string "class" or "id" - * @param {string} attributeValue - The class or id name + * @param {string} attributeValue - The class or id used name to identify the element */ function ScrollToElement(attributeName, attributeValue) { let targetEl = null; @@ -1022,46 +1148,54 @@ function initializeTooltips() { * Initialize USWDS modals by calling on method. Requires that uswds-edited.js be loaded * before get-gov.js. uswds-edited.js adds the modal module to the window to be accessible * directly in get-gov.js. - * initializeModals adds modal-related DOM elements, based on other DOM elements existing in + * load Modals adds modal-related DOM elements, based on other DOM elements existing in * the page. It needs to be called only once for any particular DOM element; otherwise, it * will initialize improperly. Therefore, if DOM elements change dynamically and include - * DOM elements with modal classes, unloadModals needs to be called before initializeModals. + * DOM elements with modal classes, uswdsUnloadModals needs to be called before loadModals. * */ -function initializeModals() { - window.modal.on(); +function uswdsInitializeModals() { + window.modal.on(); + } /** * Unload existing USWDS modals by calling off method. Requires that uswds-edited.js be * loaded before get-gov.js. uswds-edited.js adds the modal module to the window to be * accessible directly in get-gov.js. - * See note above with regards to calling this method relative to initializeModals. + * See note above with regards to calling this method relative to loadModals. * */ -function unloadModals() { +function uswdsUnloadModals() { window.modal.off(); } -class LoadTableBase { - constructor(sectionSelector) { - this.tableWrapper = document.getElementById(`${sectionSelector}__table-wrapper`); - this.tableHeaders = document.querySelectorAll(`#${sectionSelector} th[data-sortable]`); +/** + * Base table class which handles search, retrieval, rendering and interaction with results. + * Classes can extend the basic behavior of this class to customize display and interaction. + * NOTE: PLEASE notice that whatever itemName is coming in will have an "s" added to it (ie domain -> domains) + */ +class BaseTable { + constructor(itemName) { + this.itemName = itemName; + this.sectionSelector = itemName + 's'; + this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`); + this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`); this.currentSortBy = 'id'; this.currentOrder = 'asc'; this.currentStatus = []; this.currentSearchTerm = ''; this.scrollToTable = false; - this.searchInput = document.getElementById(`${sectionSelector}__search-field`); - this.searchSubmit = document.getElementById(`${sectionSelector}__search-field-submit`); - this.tableAnnouncementRegion = document.getElementById(`${sectionSelector}__usa-table__announcement-region`); - this.resetSearchButton = document.getElementById(`${sectionSelector}__reset-search`); - this.resetFiltersButton = document.getElementById(`${sectionSelector}__reset-filters`); - this.statusCheckboxes = document.querySelectorAll(`.${sectionSelector} input[name="filter-status"]`); - this.statusIndicator = document.getElementById(`${sectionSelector}__filter-indicator`); - this.statusToggle = document.getElementById(`${sectionSelector}__usa-button--filter`); - this.noTableWrapper = document.getElementById(`${sectionSelector}__no-data`); - this.noSearchResultsWrapper = document.getElementById(`${sectionSelector}__no-search-results`); + this.searchInput = document.getElementById(`${this.sectionSelector}__search-field`); + this.searchSubmit = document.getElementById(`${this.sectionSelector}__search-field-submit`); + this.tableAnnouncementRegion = document.getElementById(`${this.sectionSelector}__usa-table__announcement-region`); + this.resetSearchButton = document.getElementById(`${this.sectionSelector}__reset-search`); + this.resetFiltersButton = document.getElementById(`${this.sectionSelector}__reset-filters`); + this.statusCheckboxes = document.querySelectorAll(`.${this.sectionSelector} input[name="filter-status"]`); + this.statusIndicator = document.getElementById(`${this.sectionSelector}__filter-indicator`); + this.statusToggle = document.getElementById(`${this.sectionSelector}__usa-button--filter`); + this.noTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`); + this.noSearchResultsWrapper = document.getElementById(`${this.sectionSelector}__no-search-results`); this.portfolioElement = document.getElementById('portfolio-js-value'); this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null; this.initializeTableHeaders(); @@ -1074,31 +1208,24 @@ class LoadTableBase { } /** - * Generalized function to update pagination for a list. - * @param {string} itemName - The name displayed in the counter - * @param {string} paginationSelector - CSS selector for the pagination container. - * @param {string} counterSelector - CSS selector for the pagination counter. - * @param {string} tableSelector - CSS selector for the header element to anchor the links to. - * @param {number} currentPage - The current page number (starting with 1). - * @param {number} numPages - The total number of pages. - * @param {boolean} hasPrevious - Whether there is a page before the current page. - * @param {boolean} hasNext - Whether there is a page after the current page. - * @param {number} total - The total number of items. - */ + * Generalized function to update pagination for a list. + * @param {number} currentPage - The current page number (starting with 1). + * @param {number} numPages - The total number of pages. + * @param {boolean} hasPrevious - Whether there is a page before the current page. + * @param {boolean} hasNext - Whether there is a page after the current page. + * @param {number} total - The total number of items. + */ updatePagination( - itemName, - paginationSelector, - counterSelector, - parentTableSelector, currentPage, numPages, hasPrevious, hasNext, - totalItems, + totalItems ) { - const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`); - const counterSelectorEl = document.querySelector(counterSelector); - const paginationSelectorEl = document.querySelector(paginationSelector); + const paginationButtons = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__list`); + const counterSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__counter`); + const paginationSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination`); + const parentTableSelector = `#${this.sectionSelector}`; counterSelectorEl.innerHTML = ''; paginationButtons.innerHTML = ''; @@ -1108,12 +1235,30 @@ class LoadTableBase { // Counter should only be displayed if there is more than 1 item paginationSelectorEl.classList.toggle('display-none', totalItems < 1); - counterSelectorEl.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; + counterSelectorEl.innerHTML = `${totalItems} ${this.itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; + + // Helper function to create a pagination item, such as a + const createPaginationItem = (page) => { + const paginationItem = document.createElement('li'); + paginationItem.classList.add('usa-pagination__item', 'usa-pagination__page-no'); + paginationItem.innerHTML = ` + ${page} + `; + if (page === currentPage) { + paginationItem.querySelector('a').classList.add('usa-current'); + paginationItem.querySelector('a').setAttribute('aria-current', 'page'); + } + paginationItem.querySelector('a').addEventListener('click', (event) => { + event.preventDefault(); + this.loadTable(page); + }); + return paginationItem; + }; if (hasPrevious) { - const prevPageItem = document.createElement('li'); - prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - prevPageItem.innerHTML = ` + const prevPaginationItem = document.createElement('li'); + prevPaginationItem.className = 'usa-pagination__item usa-pagination__arrow'; + prevPaginationItem.innerHTML = ` Previous `; - prevPageItem.querySelector('a').addEventListener('click', (event) => { + prevPaginationItem.querySelector('a').addEventListener('click', (event) => { event.preventDefault(); this.loadTable(currentPage - 1); }); - paginationButtons.appendChild(prevPageItem); + paginationButtons.appendChild(prevPaginationItem); } // Add first page and ellipsis if necessary if (currentPage > 2) { - paginationButtons.appendChild(this.createPageItem(1, parentTableSelector, currentPage)); + paginationButtons.appendChild(createPaginationItem(1)); if (currentPage > 3) { const ellipsis = document.createElement('li'); ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; @@ -1142,7 +1287,7 @@ class LoadTableBase { // Add pages around the current page for (let i = Math.max(1, currentPage - 1); i <= Math.min(numPages, currentPage + 1); i++) { - paginationButtons.appendChild(this.createPageItem(i, parentTableSelector, currentPage)); + paginationButtons.appendChild(createPaginationItem(i)); } // Add last page and ellipsis if necessary @@ -1154,13 +1299,13 @@ class LoadTableBase { ellipsis.innerHTML = ''; paginationButtons.appendChild(ellipsis); } - paginationButtons.appendChild(this.createPageItem(numPages, parentTableSelector, currentPage)); + paginationButtons.appendChild(createPaginationItem(numPages)); } if (hasNext) { - const nextPageItem = document.createElement('li'); - nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - nextPageItem.innerHTML = ` + const nextPaginationItem = document.createElement('li'); + nextPaginationItem.className = 'usa-pagination__item usa-pagination__arrow'; + nextPaginationItem.innerHTML = ` Next `; - nextPageItem.querySelector('a').addEventListener('click', (event) => { + nextPaginationItem.querySelector('a').addEventListener('click', (event) => { event.preventDefault(); this.loadTable(currentPage + 1); }); - paginationButtons.appendChild(nextPageItem); + paginationButtons.appendChild(nextPaginationItem); } } /** - * A helper that toggles content/ no content/ no search results - * - */ + * A helper that toggles content/ no content/ no search results based on results in data. + * @param {Object} data - Data representing current page of results data. + * @param {HTMLElement} dataWrapper - The DOM element to show if there are results on the current page. + * @param {HTMLElement} noDataWrapper - The DOM element to show if there are no results period. + * @param {HTMLElement} noSearchResultsWrapper - The DOM element to show if there are no results in the current filtered search. + */ updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => { const { unfiltered_total, total } = data; if (unfiltered_total) { @@ -1199,24 +1347,6 @@ class LoadTableBase { } }; - // Helper function to create a page item - createPageItem(page, parentTableSelector, currentPage) { - const pageItem = document.createElement('li'); - pageItem.className = 'usa-pagination__item usa-pagination__page-no'; - pageItem.innerHTML = ` - ${page} - `; - if (page === currentPage) { - pageItem.querySelector('a').classList.add('usa-current'); - pageItem.querySelector('a').setAttribute('aria-current', 'page'); - } - pageItem.querySelector('a').addEventListener('click', (event) => { - event.preventDefault(); - this.loadTable(page); - }); - return pageItem; - } - /** * A helper that resets sortable table headers * @@ -1230,11 +1360,186 @@ class LoadTableBase { header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel); }; - // Abstract method (to be implemented in the child class) - loadTable(page, sortBy, order) { - throw new Error('loadData() must be implemented in a subclass'); + + /** + * Generates search params for filtering and sorting + * @param {number} page - The current page number for pagination (starting with 1) + * @param {*} sortBy - The sort column option + * @param {*} order - The order of sorting {asc, desc} + * @param {string} searchTerm - The search term used to filter results for a specific keyword + * @param {*} status - The status filter applied {ready, dns_needed, etc} + * @param {string} portfolio - The portfolio id + */ + getSearchParams(page, sortBy, order, searchTerm, status, portfolio) { + let searchParams = new URLSearchParams( + { + "page": page, + "sort_by": sortBy, + "order": order, + "search_term": searchTerm, + } + ); + + let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null; + let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null; + let memberOnly = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-only') : null; + + if (portfolio) + searchParams.append("portfolio", portfolio); + if (emailValue) + searchParams.append("email", emailValue); + if (memberIdValue) + searchParams.append("member_id", memberIdValue); + if (memberOnly) + searchParams.append("member_only", memberOnly); + if (status) + searchParams.append("status", status); + return searchParams; } + /** + * Gets the base URL of API requests + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + */ + getBaseUrl() { + throw new Error('getBaseUrl must be defined'); + } + + /** + * Calls "uswdsUnloadModals" to remove any existing modal element to make sure theres no unintended consequences + * from leftover event listeners + can be properly re-initialized + */ + unloadModals(){} + + /** + * Loads modals + sets up event listeners for the modal submit actions + * "Activates" the modals after the DOM updates + * Utilizes "uswdsInitializeModals" + * Adds click event listeners to each modal's submit button so we can handle a user's actions + * + * When the submit button is clicked: + * - Triggers the close button to reset modal classes + * - Determines if the page needs refreshing if the last item is deleted + * @param {number} page - The current page number for pagination + * @param {number} total - The total # of items on the current page + * @param {number} unfiltered_total - The total # of items across all pages + */ + loadModals(page, total, unfiltered_total) {} + + /** + * Allows us to customize the table display based on specific conditions and a user's permissions + * Dynamically manages the visibility set up of columns, adding/removing headers + * (ie if a domain request is deleteable, we include the kebab column or if a user has edit permissions + * for a member, they will also see the kebab column) + * @param {Object} dataObjects - Data which contains info on domain requests or a user's permission + * Currently returns a dictionary of either: + * - "needsAdditionalColumn": If a new column should be displayed + * - "UserPortfolioPermissionChoices": A user's portfolio permission choices + */ + customizeTable(dataObjects){ return {}; } + + /** + * Retrieves specific data objects + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + * Returns either: data.members, data.domains or data.domain_requests + * @param {Object} data - The full data set from which a subset of objects is extracted. + */ + getDataObjects(data) { + throw new Error('getDataObjects must be defined'); + } + + /** + * Creates + appends a row to a tbody element + * Tailored structure set up for each data object (domain, domain_request, member, etc) + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + * Returns either: data.members, data.domains or data.domain_requests + * @param {Object} dataObject - The data used to populate the row content + * @param {HTMLElement} tbody - The table body to which the new row is appended to + * @param {Object} customTableOptions - Additional options for customizing row appearance (ie needsAdditionalColumn) + */ + addRow(dataObject, tbody, customTableOptions) { + throw new Error('addRow must be defined'); + } + + /** + * See function for more details + */ + initShowMoreButtons(){} + + /** + * Loads rows in the members list, as well as updates pagination around the members list + * based on the supplied attributes. + * @param {*} page - The page number of the results (starts with 1) + * @param {*} sortBy - The sort column option + * @param {*} order - The sort order {asc, desc} + * @param {*} scroll - The control for the scrollToElement functionality + * @param {*} searchTerm - The search term + * @param {*} portfolio - The portfolio id + */ + loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + // --------- SEARCH + let searchParams = this.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); + + // --------- FETCH DATA + // fetch json of page of domains, given params + const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null; + if (!baseUrlValue) return; + + let url = `${baseUrlValue}?${searchParams.toString()}` + fetch(url) + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error('Error in AJAX call: ' + data.error); + return; + } + + // handle the display of proper messaging in the event that no members exist in the list or search returns no results + this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); + + // identify the DOM element where the list of results will be inserted into the DOM + const tbody = this.tableWrapper.querySelector('tbody'); + tbody.innerHTML = ''; + + // remove any existing modal elements from the DOM so they can be properly re-initialized + // after the DOM content changes and there are new delete modal buttons added + this.unloadModals(); + + let dataObjects = this.getDataObjects(data); + let customTableOptions = this.customizeTable(data); + + dataObjects.forEach(dataObject => { + this.addRow(dataObject, tbody, customTableOptions); + }); + + this.initShowMoreButtons(); + + this.loadModals(data.page, data.total, data.unfiltered_total); + + // Do not scroll on first page load + if (scroll) + ScrollToElement('class', this.sectionSelector); + this.scrollToTable = true; + + // update pagination + this.updatePagination( + data.page, + data.num_pages, + data.has_previous, + data.has_next, + data.total, + ); + this.currentSortBy = sortBy; + this.currentOrder = order; + this.currentSearchTerm = searchTerm; + }) + .catch(error => console.error('Error fetching objects:', error)); + } + + // Add event listeners to table headers for sorting initializeTableHeaders() { this.tableHeaders.forEach(header => { @@ -1400,150 +1705,82 @@ class LoadTableBase { } } -class DomainsTable extends LoadTableBase { +class DomainsTable extends BaseTable { constructor() { - super('domains'); + super('domain'); } - /** - * Loads rows in the domains list, as well as updates pagination around the domains list - * based on the supplied attributes. - * @param {*} page - the page number of the results (starts with 1) - * @param {*} sortBy - the sort column option - * @param {*} order - the sort order {asc, desc} - * @param {*} scroll - control for the scrollToElement functionality - * @param {*} status - control for the status filter - * @param {*} searchTerm - the search term - * @param {*} portfolio - the portfolio id - */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + getBaseUrl() { + return document.getElementById("get_domains_json_url"); + } + getDataObjects(data) { + return data.domains; + } + addRow(dataObject, tbody, customTableOptions) { + const domain = dataObject; + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; + const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; + const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; + const actionUrl = domain.action_url; + const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯'; - // fetch json of page of domais, given params - let baseUrl = document.getElementById("get_domains_json_url"); - if (!baseUrl) { - return; - } + const row = document.createElement('tr'); - let baseUrlValue = baseUrl.innerHTML; - if (!baseUrlValue) { - return; - } + let markupForSuborganizationRow = ''; - // fetch json of page of domains, given params - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "status": status, - "search_term": searchTerm - } - ); - if (portfolio) - searchParams.append("portfolio", portfolio) + if (this.portfolioValue) { + markupForSuborganizationRow = ` + + ${suborganization} + + ` + } - let url = `${baseUrlValue}?${searchParams.toString()}` - fetch(url) - .then(response => response.json()) - .then(data => { - if (data.error) { - console.error('Error in AJAX call: ' + data.error); - return; - } - - // handle the display of proper messaging in the event that no domains exist in the list or search returns no results - this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); - - // identify the DOM element where the domain list will be inserted into the DOM - const domainList = document.querySelector('#domains tbody'); - domainList.innerHTML = ''; - - data.domains.forEach(domain => { - const options = { year: 'numeric', month: 'short', day: 'numeric' }; - const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; - const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; - const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; - const actionUrl = domain.action_url; - const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯'; - - const row = document.createElement('tr'); - - let markupForSuborganizationRow = ''; - - if (this.portfolioValue) { - markupForSuborganizationRow = ` - - ${suborganization} - - ` - } - - row.innerHTML = ` - - ${domain.name} - - - ${expirationDateFormatted} - - - ${domain.state_display} - - - - - ${markupForSuborganizationRow} - - - - ${domain.action_label} ${domain.name} - - - `; - domainList.appendChild(row); - }); - // initialize tool tips immediately after the associated DOM elements are added - initializeTooltips(); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', 'domains'); - this.scrollToTable = true; - - // update pagination - this.updatePagination( - 'domain', - '#domains-pagination', - '#domains-pagination .usa-pagination__counter', - '#domains', - data.page, - data.num_pages, - data.has_previous, - data.has_next, - data.total, - ); - this.currentSortBy = sortBy; - this.currentOrder = order; - this.currentSearchTerm = searchTerm; - }) - .catch(error => console.error('Error fetching domains:', error)); + row.innerHTML = ` + + ${domain.name} + + + ${expirationDateFormatted} + + + ${domain.state_display} + + + + + ${markupForSuborganizationRow} + + + + ${domain.action_label} ${domain.name} + + + `; + tbody.appendChild(row); } } -class DomainRequestsTable extends LoadTableBase { +class DomainRequestsTable extends BaseTable { constructor() { - super('domain-requests'); + super('domain-request'); } + getBaseUrl() { + return document.getElementById("get_domain_requests_json_url"); + } + toggleExportButton(requests) { const exportButton = document.getElementById('export-csv'); if (exportButton) { @@ -1553,327 +1790,136 @@ class DomainRequestsTable extends LoadTableBase { hideElement(exportButton); } } -} + } - /** - * Loads rows in the domains list, as well as updates pagination around the domains list - * based on the supplied attributes. - * @param {*} page - the page number of the results (starts with 1) - * @param {*} sortBy - the sort column option - * @param {*} order - the sort order {asc, desc} - * @param {*} scroll - control for the scrollToElement functionality - * @param {*} status - control for the status filter - * @param {*} searchTerm - the search term - * @param {*} portfolio - the portfolio id - */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) { - let baseUrl = document.getElementById("get_domain_requests_json_url"); - - if (!baseUrl) { - return; - } + getDataObjects(data) { + return data.domain_requests; + } + unloadModals() { + uswdsUnloadModals(); + } + customizeTable(data) { - let baseUrlValue = baseUrl.innerHTML; - if (!baseUrlValue) { - return; - } + // Manage "export as CSV" visibility for domain requests + this.toggleExportButton(data.domain_requests); - // add searchParams - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "status": status, - "search_term": searchTerm + let needsDeleteColumn = data.domain_requests.some(request => request.is_deletable); + + // Remove existing delete th and td if they exist + let existingDeleteTh = document.querySelector('.delete-header'); + if (!needsDeleteColumn) { + if (existingDeleteTh) + existingDeleteTh.remove(); + } else { + if (!existingDeleteTh) { + const delheader = document.createElement('th'); + delheader.setAttribute('scope', 'col'); + delheader.setAttribute('role', 'columnheader'); + delheader.setAttribute('class', 'delete-header width-5'); + delheader.innerHTML = ` + Delete Action`; + let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); + tableHeaderRow.appendChild(delheader); } - ); - if (portfolio) - searchParams.append("portfolio", portfolio) + } + return { 'needsAdditionalColumn': needsDeleteColumn }; + } - let url = `${baseUrlValue}?${searchParams.toString()}` - fetch(url) - .then(response => response.json()) - .then(data => { - if (data.error) { - console.error('Error in AJAX call: ' + data.error); - return; + addRow(dataObject, tbody, customTableOptions) { + const request = dataObject; + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + const domainName = request.requested_domain ? request.requested_domain : `New domain request
(${utcDateString(request.created_at)})`; + const actionUrl = request.action_url; + const actionLabel = request.action_label; + const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `Not submitted`; + + // The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page) + // If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user + let modalTrigger = ` + Domain request cannot be deleted now. Edit the request for more information.`; + + let markupCreatorRow = ''; + + if (this.portfolioValue) { + markupCreatorRow = ` + + ${request.creator ? request.creator : ''} + + ` + } + + if (request.is_deletable) { + // 1st path: Just a modal trigger in any screen size for non-org users + modalTrigger = ` + + Delete ${domainName} + ` + + // Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly + if (this.portfolioValue) { + + // 2nd path: Just a modal trigger on mobile for org users or kebab + accordion with nested modal trigger on desktop for org users + modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', domainName); + } + } + + const row = document.createElement('tr'); + row.innerHTML = ` + + ${domainName} + + + ${submissionDate} + + ${markupCreatorRow} + + ${request.status} + + + + + ${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'} + + + ${customTableOptions.needsAdditionalColumn ? ''+modalTrigger+'' : ''} + `; + tbody.appendChild(row); + if (request.is_deletable) DomainRequestsTable.addDomainRequestsModal(request.requested_domain, request.id, request.created_at, tbody); + } + + loadModals(page, total, unfiltered_total) { + // initialize modals immediately after the DOM content is updated + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + let pk = submitButton.getAttribute('data-pk'); + // Workaround: Close the modal to remove the USWDS UI local classes + closeButton.click(); + // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page + let pageToDisplay = page; + if (total == 1 && unfiltered_total > 1) { + pageToDisplay--; } - - // Manage "export as CSV" visibility for domain requests - this.toggleExportButton(data.domain_requests); - - // handle the display of proper messaging in the event that no requests exist in the list or search returns no results - this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); - - // identify the DOM element where the domain request list will be inserted into the DOM - const tbody = document.querySelector('#domain-requests tbody'); - tbody.innerHTML = ''; - - // Unload modals will re-inject the DOM with the initial placeholders to allow for .on() in regular use cases - // We do NOT want that as it will cause multiple placeholders and therefore multiple inits on delete, - // which will cause bad delete requests to be sent. - const preExistingModalPlaceholders = document.querySelectorAll('[data-placeholder-for^="toggle-delete-domain-alert"]'); - preExistingModalPlaceholders.forEach(element => { - element.remove(); - }); - - // remove any existing modal elements from the DOM so they can be properly re-initialized - // after the DOM content changes and there are new delete modal buttons added - unloadModals(); - - let needsDeleteColumn = false; - - needsDeleteColumn = data.domain_requests.some(request => request.is_deletable); - - // Remove existing delete th and td if they exist - let existingDeleteTh = document.querySelector('.delete-header'); - if (!needsDeleteColumn) { - if (existingDeleteTh) - existingDeleteTh.remove(); - } else { - if (!existingDeleteTh) { - const delheader = document.createElement('th'); - delheader.setAttribute('scope', 'col'); - delheader.setAttribute('role', 'columnheader'); - delheader.setAttribute('class', 'delete-header'); - delheader.innerHTML = ` - Delete Action`; - let tableHeaderRow = document.querySelector('#domain-requests thead tr'); - tableHeaderRow.appendChild(delheader); - } - } - - data.domain_requests.forEach(request => { - const options = { year: 'numeric', month: 'short', day: 'numeric' }; - const domainName = request.requested_domain ? request.requested_domain : `New domain request
(${utcDateString(request.created_at)})`; - const actionUrl = request.action_url; - const actionLabel = request.action_label; - const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `Not submitted`; - - // The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page) - // If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user - let modalTrigger = ` - Domain request cannot be deleted now. Edit the request for more information.`; - - let markupCreatorRow = ''; - - if (this.portfolioValue) { - markupCreatorRow = ` - - ${request.creator ? request.creator : ''} - - ` - } - - if (request.is_deletable) { - // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages - let modalHeading = ''; - let modalDescription = ''; - - if (request.requested_domain) { - modalHeading = `Are you sure you want to delete ${request.requested_domain}?`; - modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; - } else { - if (request.created_at) { - modalHeading = 'Are you sure you want to delete this domain request?'; - modalDescription = `This will remove the domain request (created ${utcDateString(request.created_at)}) from the .gov registrar. This action cannot be undone`; - } else { - modalHeading = 'Are you sure you want to delete New domain request?'; - modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; - } - } - - modalTrigger = ` - - Delete ${domainName} - ` - - const modalSubmit = ` - - ` - - const modal = document.createElement('div'); - modal.setAttribute('class', 'usa-modal'); - modal.setAttribute('id', `toggle-delete-domain-alert-${request.id}`); - modal.setAttribute('aria-labelledby', 'Are you sure you want to continue?'); - modal.setAttribute('aria-describedby', 'Domain will be removed'); - modal.setAttribute('data-force-action', ''); - - modal.innerHTML = ` -
-
- -
- -
- -
- -
- ` - - this.tableWrapper.appendChild(modal); - - // Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly - if (this.portfolioValue) { - modalTrigger = ` - - Delete ${domainName} - - -
-
- -
- -
- ` - } - } - - - const row = document.createElement('tr'); - row.innerHTML = ` - - ${domainName} - - - ${submissionDate} - - ${markupCreatorRow} - - ${request.status} - - - - - ${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'} - - - ${needsDeleteColumn ? ''+modalTrigger+'' : ''} - `; - tbody.appendChild(row); - }); - - // initialize modals immediately after the DOM content is updated - initializeModals(); - - // Now the DOM and modals are ready, add listeners to the submit buttons - const modals = document.querySelectorAll('.usa-modal__content'); - - modals.forEach(modal => { - const submitButton = modal.querySelector('.usa-modal__submit'); - const closeButton = modal.querySelector('.usa-modal__close'); - submitButton.addEventListener('click', () => { - let pk = submitButton.getAttribute('data-pk'); - // Close the modal to remove the USWDS UI local classes - closeButton.click(); - // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page - let pageToDisplay = data.page; - if (data.total == 1 && data.unfiltered_total > 1) { - pageToDisplay--; - } - this.deleteDomainRequest(pk, pageToDisplay); - }); - }); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', 'domain-requests'); - this.scrollToTable = true; - - // update the pagination after the domain requests list is updated - this.updatePagination( - 'domain request', - '#domain-requests-pagination', - '#domain-requests-pagination .usa-pagination__counter', - '#domain-requests', - data.page, - data.num_pages, - data.has_previous, - data.has_next, - data.total, - ); - this.currentSortBy = sortBy; - this.currentOrder = order; - this.currentSearchTerm = searchTerm; - }) - .catch(error => console.error('Error fetching domain requests:', error)); + this.deleteDomainRequest(pk, pageToDisplay); + }); + }); } /** @@ -1903,18 +1949,186 @@ class DomainRequestsTable extends LoadTableBase { throw new Error(`HTTP error! status: ${response.status}`); } // Update data and UI - this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentSearchTerm); + this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm); }) .catch(error => console.error('Error fetching domain requests:', error)); } + + /** + * Modal that displays when deleting a domain request + * @param {string} requested_domain - The requested domain URL + * @param {string} id - The request's ID + * @param {string}} created_at - When the request was created at + * @param {HTMLElement} wrapper_element - The element to which the modal is appended + */ + static addDomainRequestsModal(requested_domain, id, created_at, wrapper_element) { + // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages + let modalHeading = ''; + let modalDescription = ''; + + if (requested_domain) { + modalHeading = `Are you sure you want to delete ${requested_domain}?`; + modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; + } else { + if (created_at) { + modalHeading = 'Are you sure you want to delete this domain request?'; + modalDescription = `This will remove the domain request (created ${utcDateString(created_at)}) from the .gov registrar. This action cannot be undone`; + } else { + modalHeading = 'Are you sure you want to delete New domain request?'; + modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; + } + } + + const modalSubmit = ` + + ` + + addModal(`toggle-delete-domain-${id}`, 'Are you sure you want to continue?', 'Domain will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true); + + } } -class MembersTable extends LoadTableBase { +class MembersTable extends BaseTable { constructor() { - super('members'); + super('member'); } + getBaseUrl() { + return document.getElementById("get_members_json_url"); + } + + // Abstract method (to be implemented in the child class) + getDataObjects(data) { + return data.members; + } + unloadModals() { + uswdsUnloadModals(); + } + loadModals(page, total, unfiltered_total) { + // initialize modals immediately after the DOM content is updated + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + let pk = submitButton.getAttribute('data-pk'); + // Close the modal to remove the USWDS UI local classes + closeButton.click(); + // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page + let pageToDisplay = page; + if (total == 1 && unfiltered_total > 1) { + pageToDisplay--; + } + + this.deleteMember(pk, pageToDisplay); + }); + }); + } + + customizeTable(data) { + // Get whether the logged in user has edit members permission + const hasEditPermission = this.portfolioElement ? this.portfolioElement.getAttribute('data-has-edit-permission')==='True' : null; + + let existingExtraActionsHeader = document.querySelector('.extra-actions-header'); + + if (hasEditPermission && !existingExtraActionsHeader) { + const extraActionsHeader = document.createElement('th'); + extraActionsHeader.setAttribute('id', 'extra-actions'); + extraActionsHeader.setAttribute('role', 'columnheader'); + extraActionsHeader.setAttribute('class', 'extra-actions-header width-5'); + extraActionsHeader.innerHTML = ` + Extra Actions`; + let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); + tableHeaderRow.appendChild(extraActionsHeader); + } + return { + 'needsAdditionalColumn': hasEditPermission, + 'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices + }; + } + + addRow(dataObject, tbody, customTableOptions) { + const member = dataObject; + // member is based on either a UserPortfolioPermission or a PortfolioInvitation + // and also includes information from related domains; the 'id' of the org_member + // is the id of the UserPorfolioPermission or PortfolioInvitation, it is not a user id + // member.type is either invitedmember or member + const unique_id = member.type + member.id; // unique string for use in dom, this is + // not the id of the associated user + const member_delete_url = member.action_url + "/delete"; + const num_domains = member.domain_urls.length; + const last_active = this.handleLastActive(member.last_active); + let cancelInvitationButton = member.type === "invitedmember" ? "Cancel invitation" : "Remove member"; + const kebabHTML = customTableOptions.needsAdditionalColumn ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member.name}`): ''; + + const row = document.createElement('tr'); + + let admin_tagHTML = ``; + if (member.is_admin) + admin_tagHTML = `Admin` + + // generate html blocks for domains and permissions for the member + let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url); + let permissionsHTML = this.generatePermissionsHTML(member.permissions, customTableOptions.UserPortfolioPermissionChoices); + + // domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand + let showMoreButton = ''; + const showMoreRow = document.createElement('tr'); + if (domainsHTML || permissionsHTML) { + showMoreButton = ` + + `; + + showMoreRow.innerHTML = `
${domainsHTML} ${permissionsHTML}
`; + showMoreRow.classList.add('show-more-content'); + showMoreRow.classList.add('display-none'); + showMoreRow.id = unique_id; + } + + row.innerHTML = ` + + ${member.member_display} ${admin_tagHTML} ${showMoreButton} + + + ${last_active.display_value} + + + + + ${member.action_label} ${member.name} + + + ${customTableOptions.needsAdditionalColumn ? ''+kebabHTML+'' : ''} + `; + tbody.appendChild(row); + if (domainsHTML || permissionsHTML) { + tbody.appendChild(showMoreRow); + } + // This easter egg is only for fixtures that dont have names as we are displaying their emails + // All prod users will have emails linked to their account + if (customTableOptions.needsAdditionalColumn) MembersTable.addMemberModal(num_domains, member.email || "Samwise Gamgee", member_delete_url, unique_id, row); + } + /** * Initializes "Show More" buttons on the page, enabling toggle functionality to show or hide content. * @@ -2042,6 +2256,86 @@ class MembersTable extends LoadTableBase { return domainsHTML; } + /** + * The POST call for deleting a Member and which error or success message it should return + * and redirection if necessary + * + * @param {string} member_delete_url - The URL for deletion ie `${member_type}-${member_id}/delete`` + * @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page + * Note: X-Request-With is used for security reasons to present CSRF attacks, the server checks that this header is present + * (consent via CORS) so it knows it's not from a random request attempt + */ + deleteMember(member_delete_url, pageToDisplay) { + // Get CSRF token + const csrfToken = getCsrfToken(); + // Create FormData object and append the CSRF token + const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}`; + + fetch(`${member_delete_url}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': csrfToken, + }, + body: formData + }) + .then(response => { + if (response.status === 200) { + response.json().then(data => { + if (data.success) { + this.addAlert("success", data.success); + } + this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm); + }); + } else { + response.json().then(data => { + if (data.error) { + // This should display the error given from backend for + // either only admin OR in progress requests + this.addAlert("error", data.error); + } else { + throw new Error(`Unexpected status: ${response.status}`); + } + }); + } + }) + .catch(error => { + console.error('Error deleting member:', error); + }); + } + + + /** + * Adds an alert message to the page with an alert class. + * + * @param {string} alertClass - {error, warning, info, success} + * @param {string} alertMessage - The text that will be displayed + * + */ + addAlert(alertClass, alertMessage) { + let toggleableAlertDiv = document.getElementById("toggleable-alert"); + this.resetAlerts(); + toggleableAlertDiv.classList.add(`usa-alert--${alertClass}`); + let alertParagraph = toggleableAlertDiv.querySelector(".usa-alert__text"); + alertParagraph.innerHTML = alertMessage + showElement(toggleableAlertDiv); + } + + /** + * Resets the reusable alert message + */ + resetAlerts() { + // Create a list of any alert that's leftover and remove + document.querySelectorAll(".usa-alert:not(#toggleable-alert)").forEach(alert => { + alert.remove(); + }); + let toggleableAlertDiv = document.getElementById("toggleable-alert"); + toggleableAlertDiv.classList.remove('usa-alert--error'); + toggleableAlertDiv.classList.remove('usa-alert--success'); + hideElement(toggleableAlertDiv); + } + /** * Generates an HTML string summarizing a user's additional permissions within a portfolio, * based on the user's permissions and predefined permission choices. @@ -2105,266 +2399,68 @@ class MembersTable extends LoadTableBase { return permissionsHTML; } - /** - * Loads rows in the members list, as well as updates pagination around the members list - * based on the supplied attributes. - * @param {*} page - the page number of the results (starts with 1) - * @param {*} sortBy - the sort column option - * @param {*} order - the sort order {asc, desc} - * @param {*} scroll - control for the scrollToElement functionality - * @param {*} searchTerm - the search term - * @param {*} portfolio - the portfolio id - */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + /** + * Modal that displays when deleting a domain request + * @param {string} num_domains - Number of domain a user has within the org + * @param {string} member_email - The member's email + * @param {string} submit_delete_url - `${member_type}-${member_id}/delete` + * @param {HTMLElement} wrapper_element - The element to which the modal is appended + */ + static addMemberModal(num_domains, member_email, submit_delete_url, id, wrapper_element) { + let modalHeading = ''; + let modalDescription = ''; + + if (num_domains == 0){ + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `They will no longer be able to access this organization. + This action cannot be undone.`; + } else if (num_domains == 1) { + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `${member_email} currently manages ${num_domains} domain in the organization. + Removing them from the organization will remove all of their domains. They will no longer be able to + access this organization. This action cannot be undone.`; + } else if (num_domains > 1) { + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `${member_email} currently manages ${num_domains} domains in the organization. + Removing them from the organization will remove all of their domains. They will no longer be able to + access this organization. This action cannot be undone.`; + } - // --------- SEARCH - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "search_term": searchTerm - } - ); - if (portfolio) - searchParams.append("portfolio", portfolio) + const modalSubmit = ` + + ` - - // --------- FETCH DATA - // fetch json of page of domains, given params - let baseUrl = document.getElementById("get_members_json_url"); - if (!baseUrl) { - return; - } - - let baseUrlValue = baseUrl.innerHTML; - if (!baseUrlValue) { - return; - } - - let url = `${baseUrlValue}?${searchParams.toString()}` //TODO: uncomment for search function - fetch(url) - .then(response => response.json()) - .then(data => { - if (data.error) { - console.error('Error in AJAX call: ' + data.error); - return; - } - - // handle the display of proper messaging in the event that no members exist in the list or search returns no results - this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); - - // identify the DOM element where the domain list will be inserted into the DOM - const memberList = document.querySelector('#members tbody'); - memberList.innerHTML = ''; - - const UserPortfolioPermissionChoices = data.UserPortfolioPermissionChoices; - const invited = 'Invited'; - const invalid_date = 'Invalid date'; - - data.members.forEach(member => { - const member_id = member.source + member.id; - const member_name = member.name; - const member_display = member.member_display; - const member_permissions = member.permissions; - const domain_urls = member.domain_urls; - const domain_names = member.domain_names; - const num_domains = domain_urls.length; - - const last_active = this.handleLastActive(member.last_active); - - const action_url = member.action_url; - const action_label = member.action_label; - const svg_icon = member.svg_icon; - - const row = document.createElement('tr'); - - let admin_tagHTML = ``; - if (member.is_admin) - admin_tagHTML = `Admin` - - // generate html blocks for domains and permissions for the member - let domainsHTML = this.generateDomainsHTML(num_domains, domain_names, domain_urls, action_url); - let permissionsHTML = this.generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices); - - // domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand - let showMoreButton = ''; - const showMoreRow = document.createElement('tr'); - if (domainsHTML || permissionsHTML) { - showMoreButton = ` - - `; - - showMoreRow.innerHTML = `
${domainsHTML} ${permissionsHTML}
`; - showMoreRow.classList.add('show-more-content'); - showMoreRow.classList.add('display-none'); - showMoreRow.id = member_id; - } - - row.innerHTML = ` - - ${member_display} ${admin_tagHTML} ${showMoreButton} - - - ${last_active.display_value} - - - - - ${action_label} ${member_name} - - - `; - memberList.appendChild(row); - if (domainsHTML || permissionsHTML) { - memberList.appendChild(showMoreRow); - } - }); - - this.initShowMoreButtons(); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', 'members'); - this.scrollToTable = true; - - // update pagination - this.updatePagination( - 'member', - '#members-pagination', - '#members-pagination .usa-pagination__counter', - '#members', - data.page, - data.num_pages, - data.has_previous, - data.has_next, - data.total, - ); - this.currentSortBy = sortBy; - this.currentOrder = order; - this.currentSearchTerm = searchTerm; - }) - .catch(error => console.error('Error fetching members:', error)); + addModal(`toggle-remove-member-${id}`, 'Are you sure you want to continue?', 'Member will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true); } } -class MemberDomainsTable extends LoadTableBase { +class MemberDomainsTable extends BaseTable { constructor() { - super('member-domains'); + super('member-domain'); this.currentSortBy = 'name'; } - /** - * Loads rows in the members list, as well as updates pagination around the members list - * based on the supplied attributes. - * @param {*} page - the page number of the results (starts with 1) - * @param {*} sortBy - the sort column option - * @param {*} order - the sort order {asc, desc} - * @param {*} scroll - control for the scrollToElement functionality - * @param {*} searchTerm - the search term - * @param {*} portfolio - the portfolio id - */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { - - // --------- SEARCH - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "search_term": searchTerm, - } - ); - - let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null; - let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null; - let memberOnly = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-only') : null; - - if (portfolio) - searchParams.append("portfolio", portfolio) - if (emailValue) - searchParams.append("email", emailValue) - if (memberIdValue) - searchParams.append("member_id", memberIdValue) - if (memberOnly) - searchParams.append("member_only", memberOnly) - - - // --------- FETCH DATA - // fetch json of page of domais, given params - let baseUrl = document.getElementById("get_member_domains_json_url"); - if (!baseUrl) { - return; - } - - let baseUrlValue = baseUrl.innerHTML; - if (!baseUrlValue) { - return; - } - - let url = `${baseUrlValue}?${searchParams.toString()}` //TODO: uncomment for search function - fetch(url) - .then(response => response.json()) - .then(data => { - if (data.error) { - console.error('Error in AJAX call: ' + data.error); - return; - } - - // handle the display of proper messaging in the event that no members exist in the list or search returns no results - this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); - - // identify the DOM element where the domain list will be inserted into the DOM - const memberDomainsList = document.querySelector('#member-domains tbody'); - memberDomainsList.innerHTML = ''; - - - data.domains.forEach(domain => { - const row = document.createElement('tr'); - - row.innerHTML = ` - - ${domain.name} - - `; - memberDomainsList.appendChild(row); - }); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', 'member-domains'); - this.scrollToTable = true; - - // update pagination - this.updatePagination( - 'member domain', - '#member-domains-pagination', - '#member-domains-pagination .usa-pagination__counter', - '#member-domains', - data.page, - data.num_pages, - data.has_previous, - data.has_next, - data.total, - ); - this.currentSortBy = sortBy; - this.currentOrder = order; - this.currentSearchTerm = searchTerm; - }) - .catch(error => console.error('Error fetching domains:', error)); + getBaseUrl() { + return document.getElementById("get_member_domains_json_url"); } + getDataObjects(data) { + return data.domains; + } + addRow(dataObject, tbody, customTableOptions) { + const domain = dataObject; + const row = document.createElement('tr'); + + row.innerHTML = ` + + ${domain.name} + + `; + tbody.appendChild(row); + } + } @@ -2776,6 +2872,46 @@ document.addEventListener('DOMContentLoaded', function() { } })(); +// This is specifically for the Member Profile (Manage Member) Page member/invitation removal +document.addEventListener("DOMContentLoaded", () => { + (function portfolioMemberPageToggle() { + const wrapperDeleteAction = document.getElementById("wrapper-delete-action") + if (wrapperDeleteAction) { + const member_type = wrapperDeleteAction.getAttribute("data-member-type"); + const member_id = wrapperDeleteAction.getAttribute("data-member-id"); + const num_domains = wrapperDeleteAction.getAttribute("data-num-domains"); + const member_name = wrapperDeleteAction.getAttribute("data-member-name"); + const member_email = wrapperDeleteAction.getAttribute("data-member-email"); + const member_delete_url = `${member_type}-${member_id}/delete`; + const unique_id = `${member_type}-${member_id}`; + + let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; + wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); + + // This easter egg is only for fixtures that dont have names as we are displaying their emails + // All prod users will have emails linked to their account + MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); + + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + let delete_member_form = document.getElementById("member-delete-form"); + if (delete_member_form) { + delete_member_form.submit(); + } + }); + }); + } + })(); +}); + /** An IIFE that intializes the requesting entity page. * This page has a radio button that dynamically toggles some fields * Within that, the dropdown also toggles some additional form elements. @@ -2828,4 +2964,4 @@ document.addEventListener('DOMContentLoaded', function() { // Add event listener to the suborg dropdown to show/hide the suborg details section select.addEventListener("change", () => toggleSuborganization()); -})(); +})(); \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_alerts.scss b/src/registrar/assets/sass/_theme/_alerts.scss index 08404232e..3cfa768fe 100644 --- a/src/registrar/assets/sass/_theme/_alerts.scss +++ b/src/registrar/assets/sass/_theme/_alerts.scss @@ -1,3 +1,4 @@ +@use "uswds-core" as *; @use "base" as *; // Fixes some font size disparities with the Figma @@ -29,3 +30,24 @@ .usa-alert__body--widescreen { max-width: $widescreen-max-width !important; } + +.usa-site-alert--hot-pink { + .usa-alert { + background-color: $hot-pink; + border-left-color: $hot-pink; + .usa-alert__body { + color: color('base-darkest'); + background-color: $hot-pink; + } + } +} + +@supports ((-webkit-mask:url()) or (mask:url())) { + .usa-site-alert--hot-pink .usa-alert .usa-alert__body::before { + background-color: color('base-darkest'); + } +} + +.usa-site-alert--hot-pink .usa-alert .usa-alert__body::before { + background-image: url('../img/usa-icons-bg/error.svg'); +} diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index d8d7fd7ee..b919a5587 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -2,6 +2,7 @@ @use "cisa_colors" as *; $widescreen-max-width: 1920px; +$hot-pink: #FFC3F9; /* Styles for making visible to screen reader / AT users only. */ .sr-only { diff --git a/src/registrar/assets/sass/_theme/_uswds-theme.scss b/src/registrar/assets/sass/_theme/_uswds-theme.scss index 6ef679734..1661a6388 100644 --- a/src/registrar/assets/sass/_theme/_uswds-theme.scss +++ b/src/registrar/assets/sass/_theme/_uswds-theme.scss @@ -119,7 +119,7 @@ in the form $setting: value, /*--------------------------- ## Emergency state ----------------------------*/ - $theme-color-emergency: #FFC3F9, + $theme-color-emergency: "red-warm-60v", /*--------------------------- # Input settings diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 18235d399..458aa5ce0 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -28,6 +28,8 @@ from registrar.views.domain_requests_json import get_domain_requests_json from registrar.views.domains_json import get_domains_json from registrar.views.utility.api_views import ( get_senior_official_from_federal_agency_json, + get_portfolio_json, + get_suborganization_list_json, get_federal_and_portfolio_types_from_federal_agency_json, get_action_needed_email_for_user_json, get_rejection_email_for_user_json, @@ -91,6 +93,11 @@ urlpatterns = [ views.PortfolioMemberView.as_view(), name="member", ), + path( + "member//delete", + views.PortfolioMemberDeleteView.as_view(), + name="member-delete", + ), path( "member//permissions", views.PortfolioMemberEditView.as_view(), @@ -106,6 +113,11 @@ urlpatterns = [ views.PortfolioInvitedMemberView.as_view(), name="invitedmember", ), + path( + "invitedmember//delete", + views.PortfolioInvitedMemberDeleteView.as_view(), + name="invitedmember-delete", + ), path( "invitedmember//permissions", views.PortfolioInvitedMemberEditView.as_view(), @@ -201,6 +213,16 @@ urlpatterns = [ get_senior_official_from_federal_agency_json, name="get-senior-official-from-federal-agency-json", ), + path( + "admin/api/get-portfolio-json/", + get_portfolio_json, + name="get-portfolio-json", + ), + path( + "admin/api/get-suborganization-list-json/", + get_suborganization_list_json, + name="get-suborganization-list-json", + ), path( "admin/api/get-federal-and-portfolio-types-from-federal-agency-json/", get_federal_and_portfolio_types_from_federal_agency_json, @@ -327,9 +349,9 @@ urlpatterns = [ name="user-profile", ), path( - "invitation//delete", - views.DomainInvitationDeleteView.as_view(http_method_names=["post"]), - name="invitation-delete", + "invitation//cancel", + views.DomainInvitationCancelView.as_view(http_method_names=["post"]), + name="invitation-cancel", ), path( "domain-request//delete", diff --git a/src/registrar/migrations/0138_alter_domaininvitation_status.py b/src/registrar/migrations/0138_alter_domaininvitation_status.py new file mode 100644 index 000000000..762a054f2 --- /dev/null +++ b/src/registrar/migrations/0138_alter_domaininvitation_status.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.10 on 2024-11-18 16:47 + +from django.db import migrations +import django_fsm + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0137_suborganization_city_suborganization_state_territory"), + ] + + operations = [ + migrations.AlterField( + model_name="domaininvitation", + name="status", + field=django_fsm.FSMField( + choices=[("invited", "Invited"), ("retrieved", "Retrieved"), ("canceled", "Canceled")], + default="invited", + max_length=50, + protected=True, + ), + ), + ] diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py index c9cbc8b39..28089dcb5 100644 --- a/src/registrar/models/domain_invitation.py +++ b/src/registrar/models/domain_invitation.py @@ -26,6 +26,7 @@ class DomainInvitation(TimeStampedModel): class DomainInvitationStatus(models.TextChoices): INVITED = "invited", "Invited" RETRIEVED = "retrieved", "Retrieved" + CANCELED = "canceled", "Canceled" email = models.EmailField( null=False, @@ -73,3 +74,13 @@ class DomainInvitation(TimeStampedModel): # something strange happened and this role already existed when # the invitation was retrieved. Log that this occurred. logger.warn("Invitation %s was retrieved for a role that already exists.", self) + + @transition(field="status", source=DomainInvitationStatus.INVITED, target=DomainInvitationStatus.CANCELED) + def cancel_invitation(self): + """When an invitation is canceled, change the status to canceled""" + pass + + @transition(field="status", source=DomainInvitationStatus.CANCELED, target=DomainInvitationStatus.INVITED) + def update_cancellation_status(self): + """When an invitation is canceled but reinvited, update the status to invited""" + pass diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 80c972d38..2d65aa02e 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -1,11 +1,12 @@ import logging +from django.apps import apps from django.contrib.auth.models import AbstractUser from django.db import models from django.db.models import Q from registrar.models import DomainInformation, UserDomainRole -from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .domain_invitation import DomainInvitation from .portfolio_invitation import PortfolioInvitation @@ -471,3 +472,42 @@ class User(AbstractUser): return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True) else: return UserDomainRole.objects.filter(user=self).values_list("id", flat=True) + + def get_active_requests_count_in_portfolio(self, request): + """Return count of active requests for the portfolio associated with the request.""" + # Get the portfolio from the session using the existing method + + portfolio = request.session.get("portfolio") + + if not portfolio: + return 0 # No portfolio found + + allowed_states = [ + DomainRequest.DomainRequestStatus.SUBMITTED, + DomainRequest.DomainRequestStatus.IN_REVIEW, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + ] + + # Now filter based on the portfolio retrieved + active_requests_count = self.domain_requests_created.filter( + status__in=allowed_states, portfolio=portfolio + ).count() + + return active_requests_count + + def is_only_admin_of_portfolio(self, portfolio): + """Check if the user is the only admin of the given portfolio.""" + + UserPortfolioPermission = apps.get_model("registrar", "UserPortfolioPermission") + + admin_permission = UserPortfolioRoleChoices.ORGANIZATION_ADMIN + + admins = UserPortfolioPermission.objects.filter(portfolio=portfolio, roles__contains=[admin_permission]) + admin_count = admins.count() + + # Check if the current user is in the list of admins + if admin_count == 1 and admins.first().user == self: + return True # The user is the only admin + + # If there are other admins or the user is not the only one + return False diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index db34fd893..5ca5edffc 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -45,7 +45,7 @@ {% block header %} {% if not IS_PRODUCTION %} {% with add_body_class="margin-left-1" %} - {% include "includes/non-production-alert.html" %} + {% include "includes/banner-non-production-alert.html" %} {% endwith %} {% endif %} diff --git a/src/registrar/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html index d6a016fd5..441890978 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -4,7 +4,25 @@ Template for an input field with a clipboard {% endcomment %} -{% if not invisible_input_field %} +{% if empty_field %} +
+ + +
+{% elif not invisible_input_field %}
{{ field }}