diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 8bdebc9fb..09d0eaa81 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -204,38 +204,177 @@ class MyUserAdminForm(UserChangeForm): ) -class UserPortfolioPermissionsForm(forms.ModelForm): - class Meta: - model = models.UserPortfolioPermission - fields = "__all__" - widgets = { - "roles": FilteredSelectMultipleArrayWidget( - "roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices - ), - "additional_permissions": FilteredSelectMultipleArrayWidget( - "additional_permissions", - is_stacked=False, - choices=UserPortfolioPermissionChoices.choices, - ), - } +class PortfolioPermissionsForm(forms.ModelForm): + """ + Form for managing portfolio permissions in Django admin. This form class is used + for both UserPortfolioPermission and PortfolioInvitation models. + + Allows selecting a portfolio, assigning a role, and managing specific permissions + related to requests, domains, and members. + """ + + # Define available permissions for requests, domains, and members + REQUEST_PERMISSIONS = [ + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, + ] + + DOMAIN_PERMISSIONS = [ + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + ] + + MEMBER_PERMISSIONS = [ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ] + + # Dropdown to select a portfolio + portfolio = forms.ModelChoiceField( + queryset=models.Portfolio.objects.all(), + label="Portfolio", + widget=AutocompleteSelectWithPlaceholder( + models.PortfolioInvitation._meta.get_field("portfolio"), + admin.site, + attrs={"data-placeholder": "---------"}, # Customize placeholder + ), + ) + + # Dropdown for selecting the user role (e.g., Admin or Basic) + role = forms.ChoiceField( + choices=[("", "---------")] + UserPortfolioRoleChoices.choices, + required=True, + widget=forms.Select(attrs={"class": "admin-dropdown"}), + label="Member access", + help_text="Only admins can manage member permissions and organization metadata.", + ) + + # Dropdown for selecting request permissions, with a default "No access" option + request_permissions = forms.ChoiceField( + choices=[(None, "No access")] + [(perm.value, perm.label) for perm in REQUEST_PERMISSIONS], + required=False, + widget=forms.Select(attrs={"class": "admin-dropdown"}), + label="Domain requests", + ) + + # Dropdown for selecting domain permissions + domain_permissions = forms.ChoiceField( + choices=[(perm.value, perm.label) for perm in DOMAIN_PERMISSIONS], + required=False, + widget=forms.Select(attrs={"class": "admin-dropdown"}), + label="Domains", + ) + + # Dropdown for selecting member permissions, with a default "No access" option + member_permissions = forms.ChoiceField( + choices=[(None, "No access")] + [(perm.value, perm.label) for perm in MEMBER_PERMISSIONS], + required=False, + widget=forms.Select(attrs={"class": "admin-dropdown"}), + label="Members", + ) + + def __init__(self, *args, **kwargs): + """ + Initialize the form and set default values based on the existing instance. + """ + super().__init__(*args, **kwargs) + + # If an instance exists, populate the form fields with existing data + if self.instance and self.instance.pk: + # Set the initial value for the role field + if self.instance.roles: + self.fields["role"].initial = self.instance.roles[0] # Assuming a single role per user + + # Set the initial values for permissions based on the instance data + if self.instance.additional_permissions: + for perm in self.instance.additional_permissions: + if perm in self.REQUEST_PERMISSIONS: + self.fields["request_permissions"].initial = perm + elif perm in self.DOMAIN_PERMISSIONS: + self.fields["domain_permissions"].initial = perm + elif perm in self.MEMBER_PERMISSIONS: + self.fields["member_permissions"].initial = perm + + def clean(self): + """ + Custom validation and processing of form data before saving. + """ + cleaned_data = super().clean() + + # Store the selected role as a list (assuming single role assignment) + self.instance.roles = [cleaned_data.get("role")] if cleaned_data.get("role") else [] + cleaned_data["roles"] = self.instance.roles + + # If the selected role is "organization_member," store additional permissions + if self.instance.roles == [UserPortfolioRoleChoices.ORGANIZATION_MEMBER]: + self.instance.additional_permissions = list( + filter( + None, + [ + cleaned_data.get("request_permissions"), + cleaned_data.get("domain_permissions"), + cleaned_data.get("member_permissions"), + ], + ) + ) + else: + # If the user is an admin, clear any additional permissions + self.instance.additional_permissions = [] + cleaned_data["additional_permissions"] = self.instance.additional_permissions + + return cleaned_data -class PortfolioInvitationAdminForm(UserChangeForm): - """This form utilizes the custom widget for its class's ManyToMany UIs.""" +class UserPortfolioPermissionsForm(PortfolioPermissionsForm): + """ + Form for managing user portfolio permissions in Django admin. + + Extends PortfolioPermissionsForm to include a user field, allowing administrators + to assign roles and permissions to specific users within a portfolio. + """ + + # Dropdown to select a user from the database + user = forms.ModelChoiceField( + queryset=models.User.objects.all(), + label="User", + widget=AutocompleteSelectWithPlaceholder( + models.UserPortfolioPermission._meta.get_field("user"), + admin.site, + attrs={"data-placeholder": "---------"}, # Customize placeholder + ), + ) class Meta: - model = models.PortfolioInvitation - fields = "__all__" - widgets = { - "roles": FilteredSelectMultipleArrayWidget( - "roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices - ), - "additional_permissions": FilteredSelectMultipleArrayWidget( - "additional_permissions", - is_stacked=False, - choices=UserPortfolioPermissionChoices.choices, - ), - } + """ + Meta class defining the model and fields to be used in the form. + """ + + model = models.UserPortfolioPermission # Uses the UserPortfolioPermission model + fields = ["user", "portfolio", "role", "domain_permissions", "request_permissions", "member_permissions"] + + +class PortfolioInvitationForm(PortfolioPermissionsForm): + """ + Form for sending portfolio invitations in Django admin. + + Extends PortfolioPermissionsForm to include an email field for inviting users, + allowing them to be assigned a role and permissions within a portfolio before they join. + """ + + class Meta: + """ + Meta class defining the model and fields to be used in the form. + """ + + model = models.PortfolioInvitation # Uses the PortfolioInvitation model + fields = [ + "email", + "portfolio", + "role", + "domain_permissions", + "request_permissions", + "member_permissions", + "status", + ] class DomainInformationAdminForm(forms.ModelForm): @@ -1345,12 +1484,13 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin): change_form_template = "django/admin/user_portfolio_permission_change_form.html" delete_confirmation_template = "django/admin/user_portfolio_permission_delete_confirmation.html" + delete_selected_confirmation_template = "django/admin/user_portfolio_permission_delete_selected_confirmation.html" def get_roles(self, obj): readable_roles = obj.get_readable_roles() return ", ".join(readable_roles) - get_roles.short_description = "Roles" # type: ignore + get_roles.short_description = "Member access" # type: ignore def delete_queryset(self, request, queryset): """We override the delete method in the model. @@ -1643,7 +1783,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin): class PortfolioInvitationAdmin(BaseInvitationAdmin): """Custom portfolio invitation admin class.""" - form = PortfolioInvitationAdminForm + form = PortfolioInvitationForm class Meta: model = models.PortfolioInvitation @@ -1655,8 +1795,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): list_display = [ "email", "portfolio", - "roles", - "additional_permissions", + "get_roles", "status", ] @@ -1681,6 +1820,13 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): change_form_template = "django/admin/portfolio_invitation_change_form.html" delete_confirmation_template = "django/admin/portfolio_invitation_delete_confirmation.html" + delete_selected_confirmation_template = "django/admin/portfolio_invitation_delete_selected_confirmation.html" + + def get_roles(self, obj): + readable_roles = obj.get_readable_roles() + return ", ".join(readable_roles) + + get_roles.short_description = "Member access" # type: ignore def save_model(self, request, obj, form, change): """ @@ -2612,17 +2758,16 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin): "investigator", "portfolio", "sub_organization", + "senior_official", ] filter_horizontal = ("current_websites", "alternative_domains", "other_contacts") # Table ordering # NOTE: This impacts the select2 dropdowns (combobox) - # Currentl, there's only one for requests on DomainInfo + # Currently, there's only one for requests on DomainInfo ordering = ["-last_submitted_date", "requested_domain__name"] - change_form_template = "django/admin/domain_request_change_form.html" - def get_fieldsets(self, request, obj=None): fieldsets = super().get_fieldsets(request, obj) @@ -4225,21 +4370,21 @@ class PortfolioAdmin(ListHeaderAdmin): if admin_count > 0: url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" # Create a clickable link with the count - return format_html(f'{admin_count} administrators') - return "No administrators found." + return format_html(f'{admin_count} admins') + return "No admins found." - display_admins.short_description = "Administrators" # type: ignore + display_admins.short_description = "Admins" # type: ignore def display_members(self, obj): - """Returns the number of members for this portfolio""" + """Returns the number of basic members for this portfolio""" member_count = len(self.get_user_portfolio_permission_non_admins(obj)) if member_count > 0: url = reverse("admin:registrar_userportfoliopermission_changelist") + f"?portfolio={obj.id}" # Create a clickable link with the count - return format_html(f'{member_count} members') - return "No additional members found." + return format_html(f'{member_count} basic members') + return "No basic members found." - display_members.short_description = "Members" # type: ignore + display_members.short_description = "Basic members" # type: ignore # Creates select2 fields (with search bars) autocomplete_fields = [ diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index ae246b05c..d8664d5bf 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -5284,7 +5284,10 @@ const setUpModal = baseComponent => { overlayDiv.classList.add(OVERLAY_CLASSNAME); // Set attributes - modalWrapper.setAttribute("role", "dialog"); + // DOTGOV + // Removes the dialog role as this causes a double readout bug with screenreaders + // modalWrapper.setAttribute("role", "dialog"); + // END DOTGOV modalWrapper.setAttribute("id", modalID); if (ariaLabelledBy) { modalWrapper.setAttribute("aria-labelledby", ariaLabelledBy); diff --git a/src/registrar/assets/src/js/getgov-admin/andi.js b/src/registrar/assets/src/js/getgov-admin/andi.js new file mode 100644 index 000000000..a6b42b966 --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/andi.js @@ -0,0 +1,60 @@ +/* +This function intercepts all select2 dropdowns and adds aria content. +It relies on an override in detail_table_fieldset.html that provides +a span with a corresponding id for aria-describedby content. + +This allows us to avoid overriding aria-label, which is used by select2 +to send the current dropdown selection to ANDI. +*/ +export function initAriaInjectionsForSelect2Dropdowns() { + document.addEventListener('DOMContentLoaded', function () { + // Find all spans with "--aria-description" in their id + const descriptionSpans = document.querySelectorAll('span[id*="--aria-description"]'); + + descriptionSpans.forEach(function (span) { + // Extract the base ID from the span's id (remove "--aria-description") + const fieldId = span.id.replace('--aria-description', ''); + const field = document.getElementById(fieldId); + + if (field) { + // If Select2 is already initialized, apply aria-describedby immediately + if (field.classList.contains('select2-hidden-accessible')) { + applyAriaDescribedBy(field, span.id); + return; + } + + // Use MutationObserver to detect Select2 initialization + const observer = new MutationObserver(function (mutations) { + if (document.getElementById(fieldId)?.classList.contains("select2-hidden-accessible")) { + applyAriaDescribedBy(field, span.id); + observer.disconnect(); // Stop observing after applying attributes + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + } + }); + + // Function to apply aria-describedby to Select2 UI + function applyAriaDescribedBy(field, descriptionId) { + let select2ElementDetected = false; + const select2Id = "select2-" + field.id + "-container"; + + // Find the Select2 selection box + const select2SpanThatTriggersAria = document.querySelector(`span[aria-labelledby='${select2Id}']`); + + if (select2SpanThatTriggersAria) { + select2SpanThatTriggersAria.setAttribute('aria-describedby', descriptionId); + select2ElementDetected = true; + } + + // If no Select2 component was detected, apply aria-describedby directly to the field + if (!select2ElementDetected) { + field.setAttribute('aria-describedby', descriptionId); + } + } + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js index b3d14839e..db6467875 100644 --- a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -1,4 +1,4 @@ -import { hideElement, showElement, addOrRemoveSessionBoolean } from './helpers-admin.js'; +import { hideElement, showElement, addOrRemoveSessionBoolean, announceForScreenReaders } from './helpers-admin.js'; import { handlePortfolioSelection } from './helpers-portfolio-dynamic-fields.js'; function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){ @@ -684,3 +684,33 @@ export function initDynamicDomainRequestFields(){ handleSuborgFieldsAndButtons(); } } + +export function initFilterFocusListeners() { + document.addEventListener("DOMContentLoaded", function() { + let filters = document.querySelectorAll("#changelist-filter li a"); // Get list of all filter links + let clickedFilter = false; // Used to determine if we are truly navigating away or not + + // Restore focus from localStorage + let lastClickedFilterId = localStorage.getItem("admin_filter_focus_id"); + if (lastClickedFilterId) { + let focusedElement = document.getElementById(lastClickedFilterId); + if (focusedElement) { + //Focus the element + focusedElement.setAttribute("tabindex", "0"); + focusedElement.focus({ preventScroll: true }); + + // Announce focus change for screen readers + announceForScreenReaders("Filter refocused on " + focusedElement.textContent); + localStorage.removeItem("admin_filter_focus_id"); + } + } + + // Capture clicked filter and store its ID + filters.forEach(filter => { + filter.addEventListener("click", function() { + localStorage.setItem("admin_filter_focus_id", this.id); + clickedFilter = true; // Mark that a filter was clicked + }); + }); + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-admin.js b/src/registrar/assets/src/js/getgov-admin/helpers-admin.js index 8055e29d3..5ec78f6b0 100644 --- a/src/registrar/assets/src/js/getgov-admin/helpers-admin.js +++ b/src/registrar/assets/src/js/getgov-admin/helpers-admin.js @@ -32,3 +32,22 @@ export function getParameterByName(name, url) { if (!results[2]) return ''; return decodeURIComponent(results[2].replace(/\+/g, ' ')); } + +/** + * Creates a temporary live region to announce messages for screen readers. + */ +export function announceForScreenReaders(message) { + let liveRegion = document.createElement("div"); + liveRegion.setAttribute("aria-live", "assertive"); + liveRegion.setAttribute("role", "alert"); + liveRegion.setAttribute("class", "usa-sr-only"); + document.body.appendChild(liveRegion); + + // Delay the update slightly to ensure it's recognized + setTimeout(() => { + liveRegion.textContent = message; + setTimeout(() => { + document.body.removeChild(liveRegion); + }, 1000); + }, 100); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov-admin/main.js b/src/registrar/assets/src/js/getgov-admin/main.js index 7eb1fc8cd..6ea73b9f3 100644 --- a/src/registrar/assets/src/js/getgov-admin/main.js +++ b/src/registrar/assets/src/js/getgov-admin/main.js @@ -10,13 +10,16 @@ import { initRejectedEmail, initApprovedDomain, initCopyRequestSummary, - initDynamicDomainRequestFields } from './domain-request-form.js'; + initDynamicDomainRequestFields, + initFilterFocusListeners } from './domain-request-form.js'; import { initDomainFormTargetBlankButtons } from './domain-form.js'; import { initDynamicPortfolioFields } from './portfolio-form.js'; +import { initDynamicPortfolioPermissionFields } from './portfolio-permissions-form.js' import { initDynamicDomainInformationFields } from './domain-information-form.js'; import { initDynamicDomainFields } from './domain-form.js'; import { initAnalyticsDashboard } from './analytics.js'; import { initButtonLinks } from './button-utils.js'; +import { initAriaInjectionsForSelect2Dropdowns } from './andi.js' // General initModals(); @@ -24,6 +27,7 @@ initCopyToClipboard(); initFilterHorizontalWidget(); initDescriptions(); initSubmitBar(); +initAriaInjectionsForSelect2Dropdowns(); initButtonLinks(); // Domain request @@ -34,6 +38,7 @@ initRejectedEmail(); initApprovedDomain(); initCopyRequestSummary(); initDynamicDomainRequestFields(); +initFilterFocusListeners(); // Domain initDomainFormTargetBlankButtons(); @@ -42,6 +47,9 @@ initDynamicDomainFields(); // Portfolio initDynamicPortfolioFields(); +// Portfolio permissions +initDynamicPortfolioPermissionFields(); + // Domain information initDynamicDomainInformationFields(); diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-permissions-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-permissions-form.js new file mode 100644 index 000000000..3e331ea46 --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-permissions-form.js @@ -0,0 +1,67 @@ +import { hideElement, showElement } from './helpers-admin.js'; + +/** + * A function for dynamically changing fields on the UserPortfolioPermissions + * and PortfolioInvitation admin forms + */ +function handlePortfolioPermissionFields(){ + + const roleDropdown = document.getElementById("id_role"); + const domainPermissionsField = document.querySelector(".field-domain_permissions"); + const domainRequestPermissionsField = document.querySelector(".field-request_permissions"); + const memberPermissionsField = document.querySelector(".field-member_permissions"); + + /** + * Updates the visibility of portfolio permissions fields based on the selected role. + * + * This function checks the value of the role dropdown (`roleDropdown`): + * - If the selected role is "organization_member": + * - Shows the domain permissions field (`domainPermissionsField`). + * - Shows the domain request permissions field (`domainRequestPermissionsField`). + * - Shows the member permissions field (`memberPermissionsField`). + * - Otherwise: + * - Hides all the above fields. + * + * The function ensures that the appropriate fields are dynamically displayed + * or hidden depending on the role selection in the form. + */ + function updatePortfolioPermissionsFormVisibility() { + if (roleDropdown && domainPermissionsField && domainRequestPermissionsField && memberPermissionsField) { + if (roleDropdown.value === "organization_member") { + showElement(domainPermissionsField); + showElement(domainRequestPermissionsField); + showElement(memberPermissionsField); + } else { + hideElement(domainPermissionsField); + hideElement(domainRequestPermissionsField); + hideElement(memberPermissionsField); + } + } + } + + + /** + * Sets event listeners for key UI elements. + */ + function setEventListeners() { + if (roleDropdown) { + roleDropdown.addEventListener("change", function() { + updatePortfolioPermissionsFormVisibility(); + }) + } + } + + // Run initial setup functions + updatePortfolioPermissionsFormVisibility(); + setEventListeners(); +} + +export function initDynamicPortfolioPermissionFields() { + document.addEventListener('DOMContentLoaded', function() { + let isPortfolioPermissionPage = document.getElementById("userportfoliopermission_form"); + let isPortfolioInvitationPage = document.getElementById("portfolioinvitation_form") + if (isPortfolioPermissionPage || isPortfolioInvitationPage) { + handlePortfolioPermissionFields(); + } + }); +} diff --git a/src/registrar/assets/src/js/getgov/domain-dnssec.js b/src/registrar/assets/src/js/getgov/domain-dnssec.js index 860359fe0..0d6ae4970 100644 --- a/src/registrar/assets/src/js/getgov/domain-dnssec.js +++ b/src/registrar/assets/src/js/getgov/domain-dnssec.js @@ -1,4 +1,4 @@ -import { submitForm } from './helpers.js'; +import { submitForm } from './form-helpers.js'; export function initDomainDNSSEC() { document.addEventListener('DOMContentLoaded', function() { diff --git a/src/registrar/assets/src/js/getgov/domain-dsdata.js b/src/registrar/assets/src/js/getgov/domain-dsdata.js index 7c0871bec..14132d812 100644 --- a/src/registrar/assets/src/js/getgov/domain-dsdata.js +++ b/src/registrar/assets/src/js/getgov/domain-dsdata.js @@ -1,4 +1,4 @@ -import { submitForm } from './helpers.js'; +import { submitForm } from './form-helpers.js'; export function initDomainDSData() { document.addEventListener('DOMContentLoaded', function() { diff --git a/src/registrar/assets/src/js/getgov/domain-managers.js b/src/registrar/assets/src/js/getgov/domain-managers.js index 26eccd8cd..3d77b7cca 100644 --- a/src/registrar/assets/src/js/getgov/domain-managers.js +++ b/src/registrar/assets/src/js/getgov/domain-managers.js @@ -1,4 +1,4 @@ -import { submitForm } from './helpers.js'; +import { submitForm } from './form-helpers.js'; export function initDomainManagersPage() { document.addEventListener('DOMContentLoaded', function() { diff --git a/src/registrar/assets/src/js/getgov/domain-request-form.js b/src/registrar/assets/src/js/getgov/domain-request-form.js index b49912fa4..73bb6accd 100644 --- a/src/registrar/assets/src/js/getgov/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov/domain-request-form.js @@ -1,4 +1,4 @@ -import { submitForm } from './helpers.js'; +import { submitForm } from './form-helpers.js'; export function initDomainRequestForm() { document.addEventListener('DOMContentLoaded', function() { diff --git a/src/registrar/assets/src/js/getgov/form-helpers.js b/src/registrar/assets/src/js/getgov/form-helpers.js new file mode 100644 index 000000000..fabfab98a --- /dev/null +++ b/src/registrar/assets/src/js/getgov/form-helpers.js @@ -0,0 +1,57 @@ +/** + * Helper function to submit a form + * @param {} form_id - the id of the form to be submitted + */ +export function submitForm(form_id) { + let form = document.getElementById(form_id); + if (form) { + form.submit(); + } else { + console.error("Form '" + form_id + "' not found."); + } +} + + +/** + * Removes all error-related classes and messages from the specified DOM element. + * This method cleans up validation errors by removing error highlighting from input fields, + * labels, and form groups, as well as deleting error message elements. + * @param {HTMLElement} domElement - The parent element within which errors should be cleared. + */ +export function removeErrorsFromElement(domElement) { + // Remove the 'usa-form-group--error' class from all div elements + domElement.querySelectorAll("div.usa-form-group--error").forEach(div => { + div.classList.remove("usa-form-group--error"); + }); + + // Remove the 'usa-label--error' class from all label elements + domElement.querySelectorAll("label.usa-label--error").forEach(label => { + label.classList.remove("usa-label--error"); + }); + + // Remove all error message divs whose ID ends with '__error-message' + domElement.querySelectorAll("div[id$='__error-message']").forEach(errorDiv => { + errorDiv.remove(); + }); + + // Remove the 'usa-input--error' class from all input elements + domElement.querySelectorAll("input.usa-input--error").forEach(input => { + input.classList.remove("usa-input--error"); + }); +} + +/** + * Removes all form-level error messages displayed in the UI. + * The form error messages are contained within div elements with the ID 'form-errors'. + * Since multiple elements with the same ID may exist (even though not syntactically correct), + * this function removes them iteratively. + */ +export function removeFormErrors() { + let formErrorDiv = document.getElementById("form-errors"); + + // Recursively remove all instances of form error divs + while (formErrorDiv) { + formErrorDiv.remove(); + formErrorDiv = document.getElementById("form-errors"); + } +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/form-nameservers.js b/src/registrar/assets/src/js/getgov/form-nameservers.js new file mode 100644 index 000000000..57b868d70 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/form-nameservers.js @@ -0,0 +1,516 @@ +import { showElement, hideElement, scrollToElement } from './helpers'; +import { removeErrorsFromElement, removeFormErrors } from './form-helpers'; + +export class NameserverForm { + constructor() { + this.addNameserverButton = document.getElementById('nameserver-add-button'); + this.addNameserversForm = document.querySelector('.add-nameservers-form'); + this.domain = ''; + this.formChanged = false; + this.callback = null; + + // Bind event handlers to maintain 'this' context + this.handleAddFormClick = this.handleAddFormClick.bind(this); + this.handleEditClick = this.handleEditClick.bind(this); + this.handleDeleteClick = this.handleDeleteClick.bind(this); + this.handleDeleteKebabClick = this.handleDeleteKebabClick.bind(this); + this.handleCancelClick = this.handleCancelClick.bind(this); + this.handleCancelAddFormClick = this.handleCancelAddFormClick.bind(this); + } + + /** + * Initialize the NameserverForm by setting up display and event listeners. + */ + init() { + this.initializeNameserverFormDisplay(); + this.initializeEventListeners(); + } + + + /** + * Determines the initial display state of the nameserver form, + * handling validation errors and setting visibility of elements accordingly. + */ + initializeNameserverFormDisplay() { + + const domainName = document.getElementById('id_form-0-domain'); + if (domainName) { + this.domain = domainName.value; + } else { + console.warn("Form expects a dom element, id_form-0-domain"); + } + + // Check if exactly two nameserver forms exist: id_form-1-server is present but id_form-2-server is not + const secondNameserver = document.getElementById('id_form-1-server'); + const thirdNameserver = document.getElementById('id_form-2-server'); // This should not exist + + // Check if there are error messages in the form (indicated by elements with class 'usa-alert--error') + const errorMessages = document.querySelectorAll('.usa-alert--error'); + + // This check indicates that there are exactly two forms (which is the case for the Add New Nameservers form) + // and there is at least one error in the form. In this case, show the Add New Nameservers form, and + // indicate that the form has changed + if (this.addNameserversForm && secondNameserver && !thirdNameserver && errorMessages.length > 0) { + showElement(this.addNameserversForm); + this.formChanged = true; + } + + // This check indicates that there is either an Add New Nameservers form or an Add New Nameserver form + // and that form has errors in it. In this case, show the form, and indicate that the form has + // changed. + if (this.addNameserversForm && this.addNameserversForm.querySelector('.usa-input--error')) { + showElement(this.addNameserversForm); + this.formChanged = true; + } + + // handle display of table view errors + // if error exists in an edit-row, make that row show, and readonly row hide + const formTable = document.getElementById('nameserver-table') + if (formTable) { + const editRows = formTable.querySelectorAll('.edit-row'); + editRows.forEach(editRow => { + if (editRow.querySelector('.usa-input--error')) { + const readOnlyRow = editRow.previousElementSibling; + this.formChanged = true; + showElement(editRow); + hideElement(readOnlyRow); + } + }) + } + + // hide ip in forms unless nameserver ends with domain name + let formIndex = 0; + while (document.getElementById('id_form-' + formIndex + '-domain')) { + let serverInput = document.getElementById('id_form-' + formIndex + '-server'); + let ipInput = document.getElementById('id_form-' + formIndex + '-ip'); + if (serverInput && ipInput) { + let serverValue = serverInput.value.trim(); // Get the value and trim spaces + let ipParent = ipInput.parentElement; // Get the parent element of ipInput + + if (ipParent && !serverValue.endsWith('.' + this.domain)) { + hideElement(ipParent); // Hide the parent element of ipInput + } + } + formIndex++; + } + } + + /** + * Attaches event listeners to relevant UI elements for interaction handling. + */ + initializeEventListeners() { + this.addNameserverButton.addEventListener('click', this.handleAddFormClick); + + const editButtons = document.querySelectorAll('.nameserver-edit'); + editButtons.forEach(editButton => { + editButton.addEventListener('click', this.handleEditClick); + }); + + const cancelButtons = document.querySelectorAll('.nameserver-cancel'); + cancelButtons.forEach(cancelButton => { + cancelButton.addEventListener('click', this.handleCancelClick); + }); + + const cancelAddFormButtons = document.querySelectorAll('.nameserver-cancel-add-form'); + cancelAddFormButtons.forEach(cancelAddFormButton => { + cancelAddFormButton.addEventListener('click', this.handleCancelAddFormClick); + }); + + const deleteButtons = document.querySelectorAll('.nameserver-delete'); + deleteButtons.forEach(deleteButton => { + deleteButton.addEventListener('click', this.handleDeleteClick); + }); + + const deleteKebabButtons = document.querySelectorAll('.nameserver-delete-kebab'); + deleteKebabButtons.forEach(deleteKebabButton => { + deleteKebabButton.addEventListener('click', this.handleDeleteKebabClick); + }); + + const textInputs = document.querySelectorAll("input[type='text']"); + textInputs.forEach(input => { + input.addEventListener("input", () => { + this.formChanged = true; + }); + }); + + // Add a specific listener for 'id_form-{number}-server' inputs to make + // nameserver forms 'smart'. Inputs on server field will change the + // display value of the associated IP address field. + let formIndex = 0; + while (document.getElementById(`id_form-${formIndex}-server`)) { + let serverInput = document.getElementById(`id_form-${formIndex}-server`); + let ipInput = document.getElementById(`id_form-${formIndex}-ip`); + if (serverInput && ipInput) { + let ipParent = ipInput.parentElement; // Get the parent element of ipInput + let ipTd = ipParent.parentElement; + // add an event listener on the server input that adjusts visibility + // and value of the ip input (and its parent) + serverInput.addEventListener("input", () => { + let serverValue = serverInput.value.trim(); + if (ipParent && ipTd) { + if (serverValue.endsWith('.' + this.domain)) { + showElement(ipParent); // Show IP field if the condition matches + ipTd.classList.add('width-40p'); + } else { + hideElement(ipParent); // Hide IP field otherwise + ipTd.classList.remove('width-40p'); + ipInput.value = ""; // Set the IP value to blank + } + } else { + console.warn("Expected DOM element but did not find it"); + } + }); + } + formIndex++; // Move to the next index + } + + // Set event listeners on the submit buttons for the modals. Event listeners + // should execute the callback function, which has its logic updated prior + // to modal display + const unsaved_changes_modal = document.getElementById('unsaved-changes-modal'); + if (unsaved_changes_modal) { + const submitButton = document.getElementById('unsaved-changes-click-button'); + const closeButton = unsaved_changes_modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + this.executeCallback(); + }); + } + const delete_modal = document.getElementById('delete-modal'); + if (delete_modal) { + const submitButton = document.getElementById('delete-click-button'); + const closeButton = delete_modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + this.executeCallback(); + }); + } + + } + + /** + * Executes a stored callback function if defined, otherwise logs a warning. + */ + executeCallback() { + if (this.callback) { + this.callback(); + this.callback = null; + } else { + console.warn("No callback function set."); + } + } + + /** + * Handles clicking the 'Add Nameserver' button, showing the form if needed. + * @param {Event} event - Click event + */ + handleAddFormClick(event) { + this.callback = () => { + // Check if any other edit row is currently visible and hide it + document.querySelectorAll('tr.edit-row:not(.display-none)').forEach(openEditRow => { + this.resetEditRowAndFormAndCollapseEditRow(openEditRow); + }); + if (this.addNameserversForm) { + // Check if this.addNameserversForm is visible (i.e., does not have 'display-none') + if (!this.addNameserversForm.classList.contains('display-none')) { + this.resetAddNameserversForm(); + } + // show nameservers form + showElement(this.addNameserversForm); + } else { + this.addAlert("error", "You’ve reached the maximum amount of name server records (13). To add another record, you’ll need to delete one of your saved records."); + } + }; + if (this.formChanged) { + //------- Show the unsaved changes confirmation modal + let modalTrigger = document.querySelector("#unsaved_changes_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } + } else { + this.executeCallback(); + } + } + + /** + * Handles clicking an 'Edit' button on a readonly row, which hides the readonly row + * and displays the edit row, after performing some checks and possibly displaying modal. + * @param {Event} event - Click event + */ + handleEditClick(event) { + let editButton = event.target; + let readOnlyRow = editButton.closest('tr'); // Find the closest row + let editRow = readOnlyRow.nextElementSibling; // Get the next row + if (!editRow || !readOnlyRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + this.callback = () => { + // Check if any other edit row is currently visible and hide it + document.querySelectorAll('tr.edit-row:not(.display-none)').forEach(openEditRow => { + this.resetEditRowAndFormAndCollapseEditRow(openEditRow); + }); + // Check if this.addNameserversForm is visible (i.e., does not have 'display-none') + if (this.addNameserversForm && !this.addNameserversForm.classList.contains('display-none')) { + this.resetAddNameserversForm(); + } + // hide and show rows as appropriate + hideElement(readOnlyRow); + showElement(editRow); + }; + if (this.formChanged) { + //------- Show the unsaved changes confirmation modal + let modalTrigger = document.querySelector("#unsaved_changes_trigger"); + if (modalTrigger) { + modalTrigger.click(); + } + } else { + this.executeCallback(); + } + } + + /** + * Handles clicking a 'Delete' button on an edit row, which hattempts to delete the nameserver + * after displaying modal and performing check for minimum number of nameservers. + * @param {Event} event - Click event + */ + handleDeleteClick(event) { + let deleteButton = event.target; + let editRow = deleteButton.closest('tr'); + if (!editRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + this.deleteRow(editRow); + } + + /** + * Handles clicking a 'Delete' button on a readonly row in a kebab, which attempts to delete the nameserver + * after displaying modal and performing check for minimum number of nameservers. + * @param {Event} event - Click event + */ + handleDeleteKebabClick(event) { + let deleteKebabButton = event.target; + let accordionDiv = deleteKebabButton.closest('div'); + // hide the accordion + accordionDiv.hidden = true; + let readOnlyRow = deleteKebabButton.closest('tr'); // Find the closest row + let editRow = readOnlyRow.nextElementSibling; // Get the next row + if (!editRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + this.deleteRow(editRow); + } + + /** + * Deletes a nameserver row after verifying the minimum required nameservers exist. + * If there are only two nameservers left, deletion is prevented, and an alert is shown. + * If deletion proceeds, the input fields are cleared, and the form is submitted. + * @param {HTMLElement} editRow - The row corresponding to the nameserver being deleted. + */ + deleteRow(editRow) { + // Check if at least two nameserver forms exist + const fourthNameserver = document.getElementById('id_form-3-server'); // This should exist + // This checks that at least 3 nameservers exist prior to the delete of a row, and if not + // display an error alert + if (fourthNameserver) { + this.callback = () => { + hideElement(editRow); + let textInputs = editRow.querySelectorAll("input[type='text']"); + textInputs.forEach(input => { + input.value = ""; + }); + document.querySelector("form").submit(); + }; + let modalTrigger = document.querySelector('#delete_trigger'); + if (modalTrigger) { + modalTrigger.click(); + } + } else { + this.addAlert("error", "At least two name servers are required. To proceed, add a new name server before removing this name server. If you need help, email us at help@get.gov."); + } + } + + /** + * Handles the click event on the "Cancel" button in the add nameserver form. + * Resets the form fields and hides the add form section. + * @param {Event} event - Click event + */ + handleCancelAddFormClick(event) { + this.resetAddNameserversForm(); + } + + /** + * Handles the click event for the cancel button within the table form. + * + * This method identifies the edit row containing the cancel button and resets + * it to its initial state, restoring the corresponding read-only row. + * + * @param {Event} event - the click event triggered by the cancel button + */ + handleCancelClick(event) { + // get the cancel button that was clicked + let cancelButton = event.target; + // find the closest table row that contains the cancel button + let editRow = cancelButton.closest('tr'); + if (editRow) { + this.resetEditRowAndFormAndCollapseEditRow(editRow); + } else { + console.warn("Expected DOM element but did not find it"); + } + } + + /** + * Resets the edit row, restores its original values, removes validation errors, + * and collapses the edit row while making the readonly row visible again. + * @param {HTMLElement} editRow - The row that is being reset and collapsed. + */ + resetEditRowAndFormAndCollapseEditRow(editRow) { + let readOnlyRow = editRow.previousElementSibling; // Get the next row + if (!editRow || !readOnlyRow) { + console.warn("Expected DOM element but did not find it"); + return; + } + // reset the values set in editRow + this.resetInputValuesInElement(editRow); + // copy values from editRow to readOnlyRow + this.copyEditRowToReadonlyRow(editRow, readOnlyRow); + // remove errors from the editRow + removeErrorsFromElement(editRow); + // remove errors from the entire form + removeFormErrors(); + // reset formChanged + this.resetFormChanged(); + // hide and show rows as appropriate + hideElement(editRow); + showElement(readOnlyRow); + } + + /** + * Resets the 'Add Nameserver' form by clearing its input fields, removing errors, + * and hiding the form to return it to its initial state. + */ + resetAddNameserversForm() { + if (this.addNameserversForm) { + // reset the values set in addNameserversForm + this.resetInputValuesInElement(this.addNameserversForm); + // remove errors from the addNameserversForm + removeErrorsFromElement(this.addNameserversForm); + // remove errors from the entire form + removeFormErrors(); + // reset formChanged + this.resetFormChanged(); + // hide the addNameserversForm + hideElement(this.addNameserversForm); + } + } + + /** + * Resets all text input fields within the specified DOM element to their initial values. + * Triggers an 'input' event to ensure any event listeners update accordingly. + * @param {HTMLElement} domElement - The parent element containing text input fields to be reset. + */ + resetInputValuesInElement(domElement) { + const inputEvent = new Event('input'); + let textInputs = domElement.querySelectorAll("input[type='text']"); + textInputs.forEach(input => { + // Reset input value to its initial stored value + input.value = input.dataset.initialValue; + // Dispatch input event to update any event-driven changes + input.dispatchEvent(inputEvent); + }); + } + + /** + * Copies values from the editable row's text inputs into the corresponding + * readonly row cells, formatting them appropriately. + * @param {HTMLElement} editRow - The row containing editable input fields. + * @param {HTMLElement} readOnlyRow - The row where values will be displayed in a non-editable format. + */ + copyEditRowToReadonlyRow(editRow, readOnlyRow) { + let textInputs = editRow.querySelectorAll("input[type='text']"); + let tds = readOnlyRow.querySelectorAll("td"); + let updatedText = ''; + + // If a server name exists, store its value + if (textInputs[0]) { + updatedText = textInputs[0].value; + } + + // If an IP address exists, append it in parentheses next to the server name + if (textInputs[1] && textInputs[1].value) { + updatedText = updatedText + " (" + textInputs[1].value + ")"; + } + + // Assign the formatted text to the first column of the readonly row + if (tds[0]) { + tds[0].innerText = updatedText; + } + } + + /** + * Resets the form change state. + * This method marks the form as unchanged by setting `formChanged` to false. + * It is useful for tracking whether a user has modified any form fields. + */ + resetFormChanged() { + this.formChanged = false; + } + + /** + * Removes all existing alert messages from the main content area. + * This ensures that only the latest alert is displayed to the user. + */ + resetAlerts() { + const mainContent = document.getElementById("main-content"); + if (mainContent) { + // Remove all alert elements within the main content area + mainContent.querySelectorAll(".usa-alert:not(.usa-alert--do-not-reset)").forEach(alert => alert.remove()); + } else { + console.warn("Expecting main-content DOM element"); + } + } + + /** + * Displays an alert message at the top of the main content area. + * It first removes any existing alerts before adding a new one to ensure only the latest alert is visible. + * @param {string} level - The alert level (e.g., 'error', 'success', 'warning', 'info'). + * @param {string} message - The message to display inside the alert. + */ + addAlert(level, message) { + this.resetAlerts(); // Remove any existing alerts before adding a new one + + const mainContent = document.getElementById("main-content"); + if (!mainContent) return; + + // Create a new alert div with appropriate classes based on alert level + const alertDiv = document.createElement("div"); + alertDiv.className = `usa-alert usa-alert--${level} usa-alert--slim margin-bottom-2`; + alertDiv.setAttribute("role", "alert"); // Add the role attribute + + // Create the alert body to hold the message text + const alertBody = document.createElement("div"); + alertBody.className = "usa-alert__body"; + alertBody.textContent = message; + + // Append the alert body to the alert div and insert it at the top of the main content area + alertDiv.appendChild(alertBody); + mainContent.insertBefore(alertDiv, mainContent.firstChild); + + // Scroll the page to make the alert visible to the user + scrollToElement("class", "usa-alert__body"); + } +} + +/** + * Initializes the NameserverForm when the DOM is fully loaded. + */ +export function initFormNameservers() { + document.addEventListener('DOMContentLoaded', () => { + if (document.getElementById('nameserver-add-button')) { + const nameserverForm = new NameserverForm(); + nameserverForm.init(); + } + }); +} \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/formset-forms.js b/src/registrar/assets/src/js/getgov/formset-forms.js index 27b85212e..b4a40e5cf 100644 --- a/src/registrar/assets/src/js/getgov/formset-forms.js +++ b/src/registrar/assets/src/js/getgov/formset-forms.js @@ -3,7 +3,7 @@ * We will call this on the forms init, and also every time we add a form * */ -function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){ +function removeForm(e, formLabel, addButton, formIdentifier){ let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); let formToRemove = e.target.closest(".repeatable-form"); formToRemove.remove(); @@ -38,48 +38,7 @@ function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){ node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`); } - - // If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required) - // inject the USWDS required markup and make sure the INPUT is required - if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) { - - // Remove the word optional - innerSpan.textContent = innerSpan.textContent.replace(/\s*\(\s*optional\s*\)\s*/, ''); - - // Create a new element - const newElement = document.createElement('abbr'); - newElement.textContent = '*'; - newElement.setAttribute("title", "required"); - newElement.classList.add("usa-hint", "usa-hint--required"); - - // Append the new element to the label - node.appendChild(newElement); - // Find the next sibling that is an input element - let nextInputElement = node.nextElementSibling; - - while (nextInputElement) { - if (nextInputElement.tagName === 'INPUT') { - // Found the next input element - nextInputElement.setAttribute("required", "") - break; - } - nextInputElement = nextInputElement.nextElementSibling; - } - nextInputElement.required = true; - } }); - - // Display the add more button if we have less than 13 forms - if (isNameserversForm && forms.length <= 13) { - addButton.removeAttribute("disabled"); - } - - if (isNameserversForm && forms.length < 3) { - // Hide the delete buttons on the remaining nameservers - Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { - deleteButton.setAttribute("disabled", "true"); - }); - } }); } @@ -131,7 +90,6 @@ function markForm(e, formLabel){ */ function prepareNewDeleteButton(btn, formLabel) { let formIdentifier = "form" - let isNameserversForm = document.querySelector(".nameservers-form"); let isOtherContactsForm = document.querySelector(".other-contacts-form"); let addButton = document.querySelector("#add-form"); @@ -144,7 +102,7 @@ function prepareNewDeleteButton(btn, formLabel) { } else { // We will remove the forms and re-order the formset btn.addEventListener('click', function(e) { - removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); + removeForm(e, formLabel, addButton, formIdentifier); }); } } @@ -157,7 +115,6 @@ function prepareNewDeleteButton(btn, formLabel) { function prepareDeleteButtons(formLabel) { let formIdentifier = "form" let deleteButtons = document.querySelectorAll(".delete-record"); - let isNameserversForm = document.querySelector(".nameservers-form"); let isOtherContactsForm = document.querySelector(".other-contacts-form"); let addButton = document.querySelector("#add-form"); if (isOtherContactsForm) { @@ -174,7 +131,7 @@ function prepareDeleteButtons(formLabel) { } else { // We will remove the forms and re-order the formset deleteButton.addEventListener('click', function(e) { - removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); + removeForm(e, formLabel, addButton, formIdentifier); }); } }); @@ -214,16 +171,14 @@ export function initFormsetsForms() { let addButton = document.querySelector("#add-form"); let cloneIndex = 0; let formLabel = ''; - let isNameserversForm = document.querySelector(".nameservers-form"); let isOtherContactsForm = document.querySelector(".other-contacts-form"); let isDsDataForm = document.querySelector(".ds-data-form"); let isDotgovDomain = document.querySelector(".dotgov-domain-form"); - // The Nameservers formset features 2 required and 11 optionals - if (isNameserversForm) { - // cloneIndex = 2; - formLabel = "Name server"; + if( !(isOtherContactsForm || isDotgovDomain || isDsDataForm) ){ + return + } // DNSSEC: DS Data - } else if (isDsDataForm) { + if (isDsDataForm) { formLabel = "DS data record"; // The Other Contacts form } else if (isOtherContactsForm) { @@ -235,11 +190,6 @@ export function initFormsetsForms() { } let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); - // On load: Disable the add more button if we have 13 forms - if (isNameserversForm && document.querySelectorAll(".repeatable-form").length == 13) { - addButton.setAttribute("disabled", "true"); - } - // Hide forms which have previously been deleted hideDeletedForms() @@ -258,33 +208,6 @@ export function initFormsetsForms() { // For the eample on Nameservers let formExampleRegex = RegExp(`ns(\\d){1}`, 'g'); - // Some Nameserver form checks since the delete can mess up the source object we're copying - // in regards to required fields and hidden delete buttons - if (isNameserversForm) { - - // If the source element we're copying has required on an input, - // reset that input - let formRequiredNeedsCleanUp = newForm.innerHTML.includes('*'); - if (formRequiredNeedsCleanUp) { - newForm.querySelector('label abbr').remove(); - // Get all input elements within the container - const inputElements = newForm.querySelectorAll("input"); - // Loop through each input element and remove the 'required' attribute - inputElements.forEach((input) => { - if (input.hasAttribute("required")) { - input.removeAttribute("required"); - } - }); - } - - // If the source element we're copying has an disabled delete button, - // enable that button - let deleteButton= newForm.querySelector('.delete-record'); - if (deleteButton.hasAttribute("disabled")) { - deleteButton.removeAttribute("disabled"); - } - } - formNum++; newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `${formIdentifier}-${formNum-1}-`); @@ -292,16 +215,20 @@ export function initFormsetsForms() { // For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden, // since the form on the backend employs Django's DELETE widget. let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length; - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`); + let newFormCount = totalShownForms + 1; + // update the header + let header = newForm.querySelector('legend h3'); + header.textContent = `${formLabel} ${newFormCount}`; + header.id = `org-contact-${newFormCount}`; + // update accessibility elements on the delete buttons + let deleteDescription = newForm.querySelector('.delete-button-description'); + deleteDescription.textContent = 'Delete new contact'; + deleteDescription.id = `org-contact-${newFormCount}__name`; + let deleteButton = newForm.querySelector('button'); + deleteButton.setAttribute("aria-labelledby", header.id); + deleteButton.setAttribute("aria-describedby", deleteDescription.id); } else { - // Nameservers form is cloned from index 2 which has the word optional on init, does not have the word optional - // if indices 0 or 1 have been deleted - let containsOptional = newForm.innerHTML.includes('(optional)'); - if (isNameserversForm && !containsOptional) { - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum} (optional)`); - } else { - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); - } + newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); } newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`); newForm.innerHTML = newForm.innerHTML.replace(/\n/g, ''); // Remove newline characters @@ -358,20 +285,6 @@ export function initFormsetsForms() { let newDeleteButton = newForm.querySelector(".delete-record"); if (newDeleteButton) prepareNewDeleteButton(newDeleteButton, formLabel); - - // Disable the add more button if we have 13 forms - if (isNameserversForm && formNum == 13) { - addButton.setAttribute("disabled", "true"); - } - - if (isNameserversForm && forms.length >= 2) { - // Enable the delete buttons on the nameservers - forms.forEach((form, index) => { - Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { - deleteButton.removeAttribute("disabled"); - }); - }); - } } } @@ -397,22 +310,3 @@ export function triggerModalOnDsDataForm() { }, 50); } } - -/** - * Disable the delete buttons on nameserver forms on page load if < 3 forms - * - */ -export function nameserversFormListener() { - let isNameserversForm = document.querySelector(".nameservers-form"); - if (isNameserversForm) { - let forms = document.querySelectorAll(".repeatable-form"); - if (forms.length < 3) { - // Hide the delete buttons on the 2 nameservers - forms.forEach((form) => { - Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { - deleteButton.setAttribute("disabled", "true"); - }); - }); - } - } -} diff --git a/src/registrar/assets/src/js/getgov/helpers.js b/src/registrar/assets/src/js/getgov/helpers.js index 08be011c2..80a9fce1f 100644 --- a/src/registrar/assets/src/js/getgov/helpers.js +++ b/src/registrar/assets/src/js/getgov/helpers.js @@ -84,19 +84,6 @@ export function getCsrfToken() { return document.querySelector('input[name="csrfmiddlewaretoken"]').value; } -/** - * Helper function to submit a form - * @param {} form_id - the id of the form to be submitted - */ -export function submitForm(form_id) { - let form = document.getElementById(form_id); - if (form) { - form.submit(); - } else { - console.error("Form '" + form_id + "' not found."); - } -} - /** * Helper function to strip HTML tags * THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index 724f8b9d0..933fe2757 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -1,6 +1,7 @@ import { hookupYesNoListener, hookupCallbacksToRadioToggler } from './radios.js'; import { initDomainValidators } from './domain-validators.js'; -import { initFormsetsForms, triggerModalOnDsDataForm, nameserversFormListener } from './formset-forms.js'; +import { initFormsetsForms, triggerModalOnDsDataForm } from './formset-forms.js'; +import { initFormNameservers } from './form-nameservers' import { initializeUrbanizationToggle } from './urbanization.js'; import { userProfileListener, finishUserSetupListener } from './user-profile.js'; import { handleRequestingEntityFieldset } from './requesting-entity.js'; @@ -16,11 +17,13 @@ import { initDomainDSData } from './domain-dsdata.js'; import { initDomainDNSSEC } from './domain-dnssec.js'; import { initFormErrorHandling } from './form-errors.js'; import { domain_purpose_choice_callbacks } from './domain-purpose-form.js'; +import { initButtonLinks } from '../getgov-admin/button-utils.js'; + initDomainValidators(); initFormsetsForms(); triggerModalOnDsDataForm(); -nameserversFormListener(); +initFormNameservers(); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); @@ -57,4 +60,6 @@ initFormErrorHandling(); // Init the portfolio new member page initPortfolioMemberPageRadio(); initPortfolioNewMemberPageToggle(); -initAddNewMemberPageListeners(); \ No newline at end of file +initAddNewMemberPageListeners(); + +initButtonLinks(); diff --git a/src/registrar/assets/src/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss index 1442acf1f..2484cd534 100644 --- a/src/registrar/assets/src/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -99,9 +99,7 @@ body { } .section-outlined__search { flex-grow: 4; - // Align right max-width: 383px; - margin-left: auto; } } } @@ -190,6 +188,9 @@ abbr[title] { .visible-mobile-flex { display: none!important; } + .text-right--tablet { + text-align: right; + } } @@ -286,3 +287,11 @@ Fit-content itself does not work. width: 3%; padding-right: 0px !important; } + +.width-40p { + width: 40%; +} + +.minh-143px { + min-height: 143px; +} diff --git a/src/registrar/assets/src/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss index 222f44fcc..509bdc573 100644 --- a/src/registrar/assets/src/sass/_theme/_tables.scss +++ b/src/registrar/assets/src/sass/_theme/_tables.scss @@ -11,6 +11,11 @@ th { border: none; } + td.padding-right-0, + th.padding-right-0 { + padding-right: 0; + } + tr:first-child th:first-child { border-top: none; } diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 12efe5a9f..56f0cfd0f 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -89,52 +89,52 @@ urlpatterns = [ name="members", ), path( - "member/", + "member/", views.PortfolioMemberView.as_view(), name="member", ), path( - "member//delete", + "member//delete", views.PortfolioMemberDeleteView.as_view(), name="member-delete", ), path( - "member//permissions", + "member//permissions", views.PortfolioMemberEditView.as_view(), name="member-permissions", ), path( - "member//domains", + "member//domains", views.PortfolioMemberDomainsView.as_view(), name="member-domains", ), path( - "member//domains/edit", + "member//domains/edit", views.PortfolioMemberDomainsEditView.as_view(), name="member-domains-edit", ), path( - "invitedmember/", + "invitedmember/", views.PortfolioInvitedMemberView.as_view(), name="invitedmember", ), path( - "invitedmember//delete", + "invitedmember//delete", views.PortfolioInvitedMemberDeleteView.as_view(), name="invitedmember-delete", ), path( - "invitedmember//permissions", + "invitedmember//permissions", views.PortfolioInvitedMemberEditView.as_view(), name="invitedmember-permissions", ), path( - "invitedmember//domains", + "invitedmember//domains", views.PortfolioInvitedMemberDomainsView.as_view(), name="invitedmember-domains", ), path( - "invitedmember//domains/edit", + "invitedmember//domains/edit", views.PortfolioInvitedMemberDomainsEditView.as_view(), name="invitedmember-domains-edit", ), diff --git a/src/registrar/decorators.py b/src/registrar/decorators.py index 7cb2792f4..b4b5c3bd2 100644 --- a/src/registrar/decorators.py +++ b/src/registrar/decorators.py @@ -1,7 +1,13 @@ +import logging import functools from django.core.exceptions import PermissionDenied from django.utils.decorators import method_decorator from registrar.models import Domain, DomainInformation, DomainInvitation, DomainRequest, UserDomainRole +from registrar.models.portfolio_invitation import PortfolioInvitation +from registrar.models.user_portfolio_permission import UserPortfolioPermission + + +logger = logging.getLogger(__name__) # Constants for clarity ALL = "all" @@ -98,24 +104,38 @@ def _user_has_permission(user, request, rules, **kwargs): if not user.is_authenticated or user.is_restricted(): return False + portfolio = request.session.get("portfolio") # Define permission checks permission_checks = [ (IS_STAFF, lambda: user.is_staff), - (IS_DOMAIN_MANAGER, lambda: _is_domain_manager(user, **kwargs)), + ( + IS_DOMAIN_MANAGER, + lambda: (not user.is_org_user(request) and _is_domain_manager(user, **kwargs)) + or ( + user.is_org_user(request) + and _is_domain_manager(user, **kwargs) + and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk")) + ), + ), (IS_STAFF_MANAGING_DOMAIN, lambda: _is_staff_managing_domain(request, **kwargs)), (IS_PORTFOLIO_MEMBER, lambda: user.is_org_user(request)), ( HAS_PORTFOLIO_DOMAINS_VIEW_ALL, - lambda: _has_portfolio_view_all_domains(request, kwargs.get("domain_pk")), + lambda: user.is_org_user(request) + and user.has_view_all_domains_portfolio_permission(portfolio) + and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk")), ), ( HAS_PORTFOLIO_DOMAINS_ANY_PERM, lambda: user.is_org_user(request) - and user.has_any_domains_portfolio_permission(request.session.get("portfolio")), + and user.has_any_domains_portfolio_permission(portfolio) + and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk")), ), ( IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER, - lambda: _is_domain_manager(user, **kwargs) and _is_portfolio_member(request), + lambda: _is_domain_manager(user, **kwargs) + and _is_portfolio_member(request) + and _domain_exists_under_portfolio(portfolio, kwargs.get("domain_pk")), ), ( IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER, @@ -129,34 +149,55 @@ def _user_has_permission(user, request, rules, **kwargs): ( HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM, lambda: user.is_org_user(request) - and user.has_any_requests_portfolio_permission(request.session.get("portfolio")), + and user.has_any_requests_portfolio_permission(portfolio) + and _domain_request_exists_under_portfolio(portfolio, kwargs.get("domain_request_pk")), ), ( HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL, lambda: user.is_org_user(request) - and user.has_view_all_domain_requests_portfolio_permission(request.session.get("portfolio")), + and user.has_view_all_domain_requests_portfolio_permission(portfolio) + and _domain_request_exists_under_portfolio(portfolio, kwargs.get("domain_request_pk")), ), ( HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT, - lambda: _has_portfolio_domain_requests_edit(user, request, kwargs.get("domain_request_pk")), + lambda: _has_portfolio_domain_requests_edit(user, request, kwargs.get("domain_request_pk")) + and _domain_request_exists_under_portfolio(portfolio, kwargs.get("domain_request_pk")), ), ( HAS_PORTFOLIO_MEMBERS_ANY_PERM, lambda: user.is_org_user(request) and ( - user.has_view_members_portfolio_permission(request.session.get("portfolio")) - or user.has_edit_members_portfolio_permission(request.session.get("portfolio")) + user.has_view_members_portfolio_permission(portfolio) + or user.has_edit_members_portfolio_permission(portfolio) + ) + and ( + # AND rather than OR because these functions return true if the PK is not found. + # This adds support for if the view simply doesn't have said PK. + _member_exists_under_portfolio(portfolio, kwargs.get("member_pk")) + and _member_invitation_exists_under_portfolio(portfolio, kwargs.get("invitedmember_pk")) ), ), ( HAS_PORTFOLIO_MEMBERS_EDIT, lambda: user.is_org_user(request) - and user.has_edit_members_portfolio_permission(request.session.get("portfolio")), + and user.has_edit_members_portfolio_permission(portfolio) + and ( + # AND rather than OR because these functions return true if the PK is not found. + # This adds support for if the view simply doesn't have said PK. + _member_exists_under_portfolio(portfolio, kwargs.get("member_pk")) + and _member_invitation_exists_under_portfolio(portfolio, kwargs.get("invitedmember_pk")) + ), ), ( HAS_PORTFOLIO_MEMBERS_VIEW, lambda: user.is_org_user(request) - and user.has_view_members_portfolio_permission(request.session.get("portfolio")), + and user.has_view_members_portfolio_permission(portfolio) + and ( + # AND rather than OR because these functions return true if the PK is not found. + # This adds support for if the view simply doesn't have said PK. + _member_exists_under_portfolio(portfolio, kwargs.get("member_pk")) + and _member_invitation_exists_under_portfolio(portfolio, kwargs.get("invitedmember_pk")) + ), ), ] @@ -191,6 +232,70 @@ def _is_domain_manager(user, **kwargs): return False +def _domain_exists_under_portfolio(portfolio, domain_pk): + """Checks to see if the given domain exists under the provided portfolio. + HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function. + Returns True if the pk is falsy. Otherwise, returns a bool if said object exists. + """ + # The view expects this, and the page will throw an error without this if it needs it. + # Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check. + if not domain_pk: + logger.warning( + "_domain_exists_under_portfolio => Could not find domain_pk. " + "This is a non-issue if called from the right context." + ) + return True + return Domain.objects.filter(domain_info__portfolio=portfolio, id=domain_pk).exists() + + +def _domain_request_exists_under_portfolio(portfolio, domain_request_pk): + """Checks to see if the given domain request exists under the provided portfolio. + HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function. + Returns True if the pk is falsy. Otherwise, returns a bool if said object exists. + """ + # The view expects this, and the page will throw an error without this if it needs it. + # Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check. + if not domain_request_pk: + logger.warning( + "_domain_request_exists_under_portfolio => Could not find domain_request_pk. " + "This is a non-issue if called from the right context." + ) + return True + return DomainRequest.objects.filter(portfolio=portfolio, id=domain_request_pk).exists() + + +def _member_exists_under_portfolio(portfolio, member_pk): + """Checks to see if the given UserPortfolioPermission exists under the provided portfolio. + HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function. + Returns True if the pk is falsy. Otherwise, returns a bool if said object exists. + """ + # The view expects this, and the page will throw an error without this if it needs it. + # Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check. + if not member_pk: + logger.warning( + "_member_exists_under_portfolio => Could not find member_pk. " + "This is a non-issue if called from the right context." + ) + return True + return UserPortfolioPermission.objects.filter(portfolio=portfolio, id=member_pk).exists() + + +def _member_invitation_exists_under_portfolio(portfolio, invitedmember_pk): + """Checks to see if the given PortfolioInvitation exists under the provided portfolio. + HELPFUL REMINDER: Watch for typos! Verify that the kwarg key exists before using this function. + Returns True if the pk is falsy. Otherwise, returns a bool if said object exists. + """ + # The view expects this, and the page will throw an error without this if it needs it. + # Thus, if it is none, we are not checking on a specific record and therefore there is nothing to check. + if not invitedmember_pk: + logger.warning( + "_member_invitation_exists_under_portfolio => Could not find invitedmember_pk. " + "This is a non-issue if called from the right context." + ) + return True + return PortfolioInvitation.objects.filter(portfolio=portfolio, id=invitedmember_pk).exists() + + def _is_domain_request_creator(user, domain_request_pk): """Checks to see if the user is the creator of a domain request with domain_request_pk.""" @@ -286,15 +391,3 @@ def _is_staff_managing_domain(request, **kwargs): # the user is permissioned, # and it is in a valid status return True - - -def _has_portfolio_view_all_domains(request, domain_pk): - """Returns whether the user in the request can access the domain - via portfolio view all domains permission.""" - portfolio = request.session.get("portfolio") - if request.user.has_view_all_domains_portfolio_permission(portfolio): - if Domain.objects.filter(id=domain_pk).exists(): - domain = Domain.objects.get(id=domain_pk) - if domain.domain_info.portfolio == portfolio: - return True - return False diff --git a/src/registrar/fixtures/fixtures_requests.py b/src/registrar/fixtures/fixtures_requests.py index 6eee6438f..15a69365c 100644 --- a/src/registrar/fixtures/fixtures_requests.py +++ b/src/registrar/fixtures/fixtures_requests.py @@ -319,31 +319,23 @@ class DomainRequestFixture: """Creates DomainRequests given a list of users.""" total_domain_requests_to_make = len(users) # 100000 - # Check if the database is already populated with the desired - # number of entries. - # (Prevents re-adding more entries to an already populated database, - # which happens when restarting Docker src) - domain_requests_already_made = DomainRequest.objects.count() - domain_requests_to_create = [] - if domain_requests_already_made < total_domain_requests_to_make: - for user in users: - for request_data in cls.DOMAINREQUESTS: - # Prepare DomainRequest objects - try: - domain_request = DomainRequest( - creator=user, - organization_name=request_data["organization_name"], - ) - cls._set_non_foreign_key_fields(domain_request, request_data) - cls._set_foreign_key_fields(domain_request, request_data, user) - domain_requests_to_create.append(domain_request) - except Exception as e: - logger.warning(e) - num_additional_requests_to_make = ( - total_domain_requests_to_make - domain_requests_already_made - len(domain_requests_to_create) - ) + for user in users: + for request_data in cls.DOMAINREQUESTS: + # Prepare DomainRequest objects + try: + domain_request = DomainRequest( + creator=user, + organization_name=request_data["organization_name"], + ) + cls._set_non_foreign_key_fields(domain_request, request_data) + cls._set_foreign_key_fields(domain_request, request_data, user) + domain_requests_to_create.append(domain_request) + except Exception as e: + logger.warning(e) + + num_additional_requests_to_make = total_domain_requests_to_make - len(domain_requests_to_create) if num_additional_requests_to_make > 0: for _ in range(num_additional_requests_to_make): random_user = random.choice(users) # nosec diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 05eb90db3..538edc7ab 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -65,7 +65,12 @@ class DomainNameserverForm(forms.Form): domain = forms.CharField(widget=forms.HiddenInput, required=False) - server = forms.CharField(label="Name server", strip=True) + server = forms.CharField( + label="Name server", + strip=True, + required=True, + error_messages={"required": "At least two name servers are required."}, + ) ip = forms.CharField( label="IP address (IPv4 or IPv6)", @@ -76,13 +81,6 @@ class DomainNameserverForm(forms.Form): def __init__(self, *args, **kwargs): super(DomainNameserverForm, self).__init__(*args, **kwargs) - # add custom error messages - self.fields["server"].error_messages.update( - { - "required": "At least two name servers are required.", - } - ) - def clean(self): # clean is called from clean_forms, which is called from is_valid # after clean_fields. it is used to determine form level errors. @@ -183,43 +181,83 @@ class DomainSuborganizationForm(forms.ModelForm): class BaseNameserverFormset(forms.BaseFormSet): def clean(self): - """ - Check for duplicate entries in the formset. - """ + """Check for duplicate entries in the formset and ensure at least two valid nameservers.""" + error_message = "At least two name servers are required." - # Check if there are at least two valid servers - valid_servers_count = sum( - 1 for form in self.forms if form.cleaned_data.get("server") and form.cleaned_data.get("server").strip() - ) - if valid_servers_count >= 2: - # If there are, remove the "At least two name servers are required" error from each form - # This will allow for successful submissions when the first or second entries are blanked - # but there are enough entries total - for form in self.forms: - if form.errors.get("server") == ["At least two name servers are required."]: - form.errors.pop("server") + valid_forms, invalid_forms, empty_forms = self._categorize_forms(error_message) + self._enforce_minimum_nameservers(valid_forms, invalid_forms, empty_forms, error_message) - if any(self.errors): - # Don't bother validating the formset unless each form is valid on its own + if any(self.errors): # Skip further validation if individual forms already have errors return - data = [] + self._check_for_duplicates() + + def _categorize_forms(self, error_message): + """Sort forms into valid, invalid or empty based on the 'server' field.""" + valid_forms = [] + invalid_forms = [] + empty_forms = [] + + for form in self.forms: + if not self._is_server_validation_needed(form, error_message): + invalid_forms.append(form) + continue + server = form.cleaned_data.get("server", "").strip() + if server: + valid_forms.append(form) + else: + empty_forms.append(form) + + return valid_forms, invalid_forms, empty_forms + + def _is_server_validation_needed(self, form, error_message): + """Determine if server validation should be performed on a given form.""" + return form.is_valid() or list(form.errors.get("server", [])) == [error_message] + + def _enforce_minimum_nameservers(self, valid_forms, invalid_forms, empty_forms, error_message): + """Ensure at least two nameservers are provided, adjusting error messages as needed.""" + if len(valid_forms) + len(invalid_forms) < 2: + self._add_required_error(empty_forms, error_message) + else: + self._remove_required_error_from_forms(error_message) + + def _add_required_error(self, empty_forms, error_message): + """Add 'At least two name servers' error to one form and remove duplicates.""" + error_added = False + + for form in empty_forms: + if list(form.errors.get("server", [])) == [error_message]: + form.errors.pop("server") + + if not error_added: + form.add_error("server", error_message) + error_added = True + + def _remove_required_error_from_forms(self, error_message): + """Remove the 'At least two name servers' error from all forms if sufficient nameservers exist.""" + for form in self.forms: + if form.errors.get("server") == [error_message]: + form.errors.pop("server") + + def _check_for_duplicates(self): + """Ensure no duplicate nameservers exist within the formset.""" + seen_servers = set() duplicates = [] - for index, form in enumerate(self.forms): - if form.cleaned_data: - value = form.cleaned_data["server"] - # We need to make sure not to trigger the duplicate error in case the first and second nameservers - # are empty. If there are enough records in the formset, that error is an unecessary blocker. - # If there aren't, the required error will block the submit. - if value in data and not (form.cleaned_data.get("server", "").strip() == "" and index == 1): - form.add_error( - "server", - NameserverError(code=nsErrorCodes.DUPLICATE_HOST, nameserver=value), - ) - duplicates.append(value) - else: - data.append(value) + for form in self.forms: + if not form.cleaned_data: + continue + + server = form.cleaned_data["server"].strip() + + if server and server in seen_servers: + form.add_error( + "server", + NameserverError(code=nsErrorCodes.DUPLICATE_HOST, nameserver=server), + ) + duplicates.append(server) + else: + seen_servers.add(server) NameserverFormset = formset_factory( diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index 9192153b7..0693ae837 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -348,7 +348,7 @@ class OrganizationContactForm(RegistrarForm): error_messages={ "required": ("Select the state, territory, or military post where your organization is located.") }, - widget=ComboboxWidget, + widget=ComboboxWidget(attrs={"required": True}), ) zipcode = forms.CharField( label="Zip code", @@ -608,37 +608,25 @@ class DotGovDomainForm(RegistrarForm): ) -class ExecutiveNamingRequirementsYesNoForm(BaseYesNoForm, BaseDeletableRegistrarForm): - """ - Form for verifying if the domain request meets the Federal Executive Branch domain naming requirements. - If the "no" option is selected, details must be provided via the separate details form. - """ +class PurposeDetailsForm(BaseDeletableRegistrarForm): - field_name = "feb_naming_requirements" + field_name = "purpose" - @property - def form_is_checked(self): - """ - Determines the initial checked state of the form based on the domain_request's attributes. - """ - return self.domain_request.feb_naming_requirements - - -class ExecutiveNamingRequirementsDetailsForm(BaseDeletableRegistrarForm): - # Text area for additional details; rendered conditionally when "no" is selected. - feb_naming_requirements_details = forms.CharField( - widget=forms.Textarea(attrs={"maxlength": "2000"}), - max_length=2000, - required=True, - error_messages={"required": ("This field is required.")}, + purpose = forms.CharField( + label="Purpose", + widget=forms.Textarea( + attrs={ + "aria-label": "What is the purpose of your requested domain? Describe how you’ll use your .gov domain. \ + Will it be used for a website, email, or something else?" + } + ), validators=[ MaxLengthValidator( 2000, message="Response must be less than 2000 characters.", ) ], - label="", - help_text="Maximum 2000 characters allowed.", + error_messages={"required": "Describe how you’ll use the .gov domain you’re requesting."}, ) diff --git a/src/registrar/forms/feb.py b/src/registrar/forms/feb.py index 839a659e6..4342acdba 100644 --- a/src/registrar/forms/feb.py +++ b/src/registrar/forms/feb.py @@ -3,6 +3,40 @@ from django.core.validators import MaxLengthValidator from registrar.forms.utility.wizard_form_helper import BaseDeletableRegistrarForm, BaseYesNoForm from registrar.models.contact import Contact +class ExecutiveNamingRequirementsYesNoForm(BaseYesNoForm, BaseDeletableRegistrarForm): + """ + Form for verifying if the domain request meets the Federal Executive Branch domain naming requirements. + If the "no" option is selected, details must be provided via the separate details form. + """ + + field_name = "feb_naming_requirements" + + @property + def form_is_checked(self): + """ + Determines the initial checked state of the form based on the domain_request's attributes. + """ + return self.domain_request.feb_naming_requirements + + +class ExecutiveNamingRequirementsDetailsForm(BaseDeletableRegistrarForm): + # Text area for additional details; rendered conditionally when "no" is selected. + feb_naming_requirements_details = forms.CharField( + widget=forms.Textarea(attrs={"maxlength": "2000"}), + max_length=2000, + required=True, + error_messages={"required": ("This field is required.")}, + validators=[ + MaxLengthValidator( + 2000, + message="Response must be less than 2000 characters.", + ) + ], + label="", + help_text="Maximum 2000 characters allowed.", + ) + + class FEBPurposeOptionsForm(BaseDeletableRegistrarForm): field_name = "feb_purpose_choice" diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 5aef09389..b83e718cb 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -445,3 +445,28 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm): class Meta: model = PortfolioInvitation fields = ["portfolio", "email", "roles", "additional_permissions"] + + def _post_clean(self): + """ + Override _post_clean to customize model validation errors. + This runs after form clean is complete, but before the errors are displayed. + """ + try: + super()._post_clean() + self.instance.clean() + except forms.ValidationError as e: + override_error = False + if hasattr(e, "code"): + field = "email" if "email" in self.fields else None + if e.code == "has_existing_permissions": + self.add_error(field, f"{self.instance.email} is already a member of another .gov organization.") + override_error = True + elif e.code == "has_existing_invitations": + self.add_error( + field, f"{self.instance.email} has already been invited to another .gov organization." + ) + override_error = True + + # Errors denoted as "__all__" are special error types reserved for the model level clean function + if override_error and "__all__" in self._errors: + del self._errors["__all__"] diff --git a/src/registrar/management/commands/disclose_security_emails.py b/src/registrar/management/commands/disclose_security_emails.py index 62456747a..15c0bb750 100644 --- a/src/registrar/management/commands/disclose_security_emails.py +++ b/src/registrar/management/commands/disclose_security_emails.py @@ -1,4 +1,4 @@ -""" " +""" Converts all ready and DNS needed domains with a non-default public contact to disclose their public contact. Created for Issue#1535 to resolve disclose issue of domains with missing security emails. diff --git a/src/registrar/management/commands/populate_domain_updated_federal_agency.py b/src/registrar/management/commands/populate_domain_updated_federal_agency.py index 3fbf2792c..1c7d1e980 100644 --- a/src/registrar/management/commands/populate_domain_updated_federal_agency.py +++ b/src/registrar/management/commands/populate_domain_updated_federal_agency.py @@ -1,4 +1,4 @@ -""" " +""" Data migration: Renaming deprecated Federal Agencies to their new updated names ie (U.S. Peace Corps to Peace Corps) within Domain Information and Domain Requests diff --git a/src/registrar/migrations/0141_alter_portfolioinvitation_additional_permissions_and_more.py b/src/registrar/migrations/0141_alter_portfolioinvitation_additional_permissions_and_more.py new file mode 100644 index 000000000..c100f04b2 --- /dev/null +++ b/src/registrar/migrations/0141_alter_portfolioinvitation_additional_permissions_and_more.py @@ -0,0 +1,86 @@ +# Generated by Django 4.2.17 on 2025-02-28 17:11 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0140_alter_portfolioinvitation_additional_permissions_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="portfolioinvitation", + name="additional_permissions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "Viewer"), + ("view_managed_domains", "Viewer, limited (domains they manage)"), + ("view_members", "Viewer"), + ("edit_members", "Manager"), + ("view_all_requests", "Viewer"), + ("edit_requests", "Creator"), + ("view_portfolio", "Viewer"), + ("edit_portfolio", "Manager"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="portfolioinvitation", + name="roles", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("organization_admin", "Admin"), ("organization_member", "Basic")], max_length=50 + ), + blank=True, + help_text="Select one or more roles.", + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="userportfoliopermission", + name="additional_permissions", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "Viewer"), + ("view_managed_domains", "Viewer, limited (domains they manage)"), + ("view_members", "Viewer"), + ("edit_members", "Manager"), + ("view_all_requests", "Viewer"), + ("edit_requests", "Creator"), + ("view_portfolio", "Viewer"), + ("edit_portfolio", "Manager"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + migrations.AlterField( + model_name="userportfoliopermission", + name="roles", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[("organization_admin", "Admin"), ("organization_member", "Basic")], max_length=50 + ), + blank=True, + help_text="Select one or more roles.", + null=True, + size=None, + ), + ), + ] diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index 7056a4032..f80ab08d0 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -1452,6 +1452,7 @@ class DomainRequest(TimeStampedModel): if self.portfolio: return self.portfolio.federal_type == BranchChoices.EXECUTIVE return False + def is_federal(self) -> Union[bool, None]: """Is this domain request for a federal agency? diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index fafa99856..1da0fcdd1 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -15,6 +15,7 @@ from .utility.portfolio_helper import ( get_domains_display, get_members_description_display, get_members_display, + get_readable_roles, get_role_display, validate_portfolio_invitation, ) # type: ignore @@ -78,6 +79,10 @@ class PortfolioInvitation(TimeStampedModel): def __str__(self): return f"Invitation for {self.email} on {self.portfolio} is {self.status}" + def get_readable_roles(self): + """Returns a readable list of self.roles""" + return get_readable_roles(self.roles) + def get_managed_domains_count(self): """Return the count of domain invitations managed by the invited user for this portfolio.""" # Filter the UserDomainRole model to get domains where the user has a manager role diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 0a758ff6a..38a87722d 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -12,6 +12,7 @@ from registrar.models.utility.portfolio_helper import ( get_domains_description_display, get_members_display, get_members_description_display, + get_readable_roles, get_role_display, validate_user_portfolio_permission, ) @@ -94,12 +95,7 @@ class UserPortfolioPermission(TimeStampedModel): def get_readable_roles(self): """Returns a readable list of self.roles""" - readable_roles = [] - if self.roles: - readable_roles = sorted( - [UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in self.roles] - ) - return readable_roles + return get_readable_roles(self.roles) def get_managed_domains_count(self): """Return the count of domains managed by the user for this portfolio.""" @@ -275,7 +271,12 @@ class UserPortfolioPermission(TimeStampedModel): def clean(self): """Extends clean method to perform additional validation, which can raise errors in django admin.""" super().clean() - validate_user_portfolio_permission(self) + # Ensure user exists before running further validation + # In django admin, this clean method is called before form validation checks + # for required fields. Since validation below requires user, skip if user does + # not exist + if self.user_id: + validate_user_portfolio_permission(self) def delete(self, *args, **kwargs): diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index e94733fb6..009ea3c26 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -16,7 +16,7 @@ class UserPortfolioRoleChoices(models.TextChoices): """ ORGANIZATION_ADMIN = "organization_admin", "Admin" - ORGANIZATION_MEMBER = "organization_member", "Member" + ORGANIZATION_MEMBER = "organization_member", "Basic" @classmethod def get_user_portfolio_role_label(cls, user_portfolio_role): @@ -30,17 +30,17 @@ class UserPortfolioRoleChoices(models.TextChoices): class UserPortfolioPermissionChoices(models.TextChoices): """ """ - VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports" - VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains" + VIEW_ALL_DOMAINS = "view_all_domains", "Viewer" + VIEW_MANAGED_DOMAINS = "view_managed_domains", "Viewer, limited (domains they manage)" - VIEW_MEMBERS = "view_members", "View members" - EDIT_MEMBERS = "edit_members", "Create and edit members" + VIEW_MEMBERS = "view_members", "Viewer" + EDIT_MEMBERS = "edit_members", "Manager" - VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" - EDIT_REQUESTS = "edit_requests", "Create and edit requests" + VIEW_ALL_REQUESTS = "view_all_requests", "Viewer" + EDIT_REQUESTS = "edit_requests", "Creator" - VIEW_PORTFOLIO = "view_portfolio", "View organization" - EDIT_PORTFOLIO = "edit_portfolio", "Edit organization" + VIEW_PORTFOLIO = "view_portfolio", "Viewer" + EDIT_PORTFOLIO = "edit_portfolio", "Manager" @classmethod def get_user_portfolio_permission_label(cls, user_portfolio_permission): @@ -79,6 +79,13 @@ class MemberPermissionDisplay(StrEnum): NONE = "None" +def get_readable_roles(roles): + readable_roles = [] + if roles: + readable_roles = sorted([UserPortfolioRoleChoices.get_user_portfolio_role_label(role) for role in roles]) + return readable_roles + + def get_role_display(roles): """ Returns a user-friendly display name for a given list of user roles. @@ -285,7 +292,8 @@ def validate_user_portfolio_permission(user_portfolio_permission): if existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " - "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios.", + code="has_existing_permissions", ) existing_invitations = PortfolioInvitation.objects.filter(email=user_portfolio_permission.user.email).exclude( @@ -295,7 +303,8 @@ def validate_user_portfolio_permission(user_portfolio_permission): if existing_invitations.exists(): raise ValidationError( "This user is already assigned to a portfolio invitation. " - "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios.", + code="has_existing_invitations", ) @@ -343,6 +352,7 @@ def validate_portfolio_invitation(portfolio_invitation): # == Validate the multiple_porfolios flag. == # user = User.objects.filter(email=portfolio_invitation.email).first() + # If user returns None, then we check for global assignment of multiple_portfolios. # Otherwise we just check on the user. if not flag_is_active_for_user(user, "multiple_portfolios"): @@ -355,13 +365,15 @@ def validate_portfolio_invitation(portfolio_invitation): if existing_permissions.exists(): raise ValidationError( "This user is already assigned to a portfolio. " - "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios.", + code="has_existing_permissions", ) if existing_invitations.exists(): raise ValidationError( "This user is already assigned to a portfolio invitation. " - "Based on current waffle flag settings, users cannot be assigned to multiple portfolios." + "Based on current waffle flag settings, users cannot be assigned to multiple portfolios.", + code="has_existing_invitations", ) diff --git a/src/registrar/templates/admin/filter.html b/src/registrar/templates/admin/filter.html new file mode 100644 index 000000000..abe3ad282 --- /dev/null +++ b/src/registrar/templates/admin/filter.html @@ -0,0 +1,13 @@ +{% comment %} Override of this file: https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/filter.html {% endcomment %} +{% load i18n %} +
+ + {% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %} + + +
\ No newline at end of file diff --git a/src/registrar/templates/admin/model_descriptions.html b/src/registrar/templates/admin/model_descriptions.html index 9f13245fe..9b163c407 100644 --- a/src/registrar/templates/admin/model_descriptions.html +++ b/src/registrar/templates/admin/model_descriptions.html @@ -30,6 +30,8 @@ {% include "django/admin/includes/descriptions/verified_by_staff_description.html" %} {% elif opts.model_name == 'website' %} {% include "django/admin/includes/descriptions/website_description.html" %} + {% elif opts.model_name == 'userportfoliopermission' %} + {% include "django/admin/includes/descriptions/user_portfolio_permission_description.html" %} {% elif opts.model_name == 'portfolioinvitation' %} {% include "django/admin/includes/descriptions/portfolio_invitation_description.html" %} {% elif opts.model_name == 'allowedemail' %} diff --git a/src/registrar/templates/django/admin/domain_invitation_change_form.html b/src/registrar/templates/django/admin/domain_invitation_change_form.html index 699760fa8..886cfc834 100644 --- a/src/registrar/templates/django/admin/domain_invitation_change_form.html +++ b/src/registrar/templates/django/admin/domain_invitation_change_form.html @@ -6,7 +6,11 @@

- If you add someone to a domain here, it will trigger emails to the invitee and all managers of the domain when you click "save." If you don't want to trigger those emails, use the User domain roles permissions table instead. + If you invite someone to a domain here, it will trigger email notifications. If you don't want to trigger emails, use the + + User Domain Roles + + table instead.

diff --git a/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html index 215bf5ada..7315c9ddc 100644 --- a/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html +++ b/src/registrar/templates/django/admin/domain_invitation_delete_confirmation.html @@ -5,10 +5,12 @@ diff --git a/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html b/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html index 2e15347c1..8c1614f5d 100644 --- a/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html +++ b/src/registrar/templates/django/admin/domain_invitation_delete_selected_confirmation.html @@ -5,10 +5,12 @@ diff --git a/src/registrar/templates/django/admin/includes/descriptions/domain_invitation_description.html b/src/registrar/templates/django/admin/includes/descriptions/domain_invitation_description.html index ff277a444..6b2cae5ce 100644 --- a/src/registrar/templates/django/admin/includes/descriptions/domain_invitation_description.html +++ b/src/registrar/templates/django/admin/includes/descriptions/domain_invitation_description.html @@ -1,16 +1,14 @@

-Domain invitations contain all individuals who have been invited to manage a .gov domain. -Invitations are sent via email, and the recipient must log in to the registrar to officially -accept and become a domain manager. + This table contains all individuals who have been invited to manage a .gov domain. + These individuals must log in to the registrar to officially accept and become a domain manager.

-An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent. Deleting an invitation with an "invited" status will prevent the user from signing in. -A “received” status indicates that the recipient has logged in. Deleting an invitation with a "received" status will not revoke that user's access from the domain. To remove a user who has already signed in, go to User domain roles and delete the role for the correct domain/manager combination. + An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent. + A “received” status indicates that the recipient has logged in.

-If an invitation is created in this table, an email will not be sent. -To have an email sent, go to the domain in Domains, -click the “Manage domain” button, and add a domain manager. + If you invite someone to a domain by using this table, they’ll receive an email notification. + The existing managers of the domain will also be notified. However, canceling an invitation here won’t trigger any emails.

diff --git a/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html b/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html index 51515bcb2..9a486f944 100644 --- a/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html +++ b/src/registrar/templates/django/admin/includes/descriptions/portfolio_invitation_description.html @@ -1,11 +1,15 @@

-Portfolio invitations contain all individuals who have been invited to become members of an organization. -Invitations are sent via email, and the recipient must log in to the registrar to officially -accept and become a member. + This table contains all individuals who have been invited to become members of a portfolio. + These individuals must log in to the registrar to officially accept and become a member.

-An “invited” status indicates that the recipient has not logged in to the registrar since the invitation was sent -or that the recipient has logged in but is already a member of an organization. -A “received” status indicates that the recipient has logged in. + An “invited” status indicates that the recipient has not logged in to the registrar since the invitation + was sent or that the recipient has logged in but is already a member of another portfolio. A “received” + status indicates that the recipient has logged in. +

+ +

+ If you invite someone to a portfolio by using this table, they’ll receive an email notification. + If you assign them "admin" access, the existing portfolio admins will also be notified. However, canceling an invitation here won’t trigger any emails.

diff --git a/src/registrar/templates/django/admin/includes/descriptions/user_domain_role_description.html b/src/registrar/templates/django/admin/includes/descriptions/user_domain_role_description.html index 7066fcb93..f0e7f5a5f 100644 --- a/src/registrar/templates/django/admin/includes/descriptions/user_domain_role_description.html +++ b/src/registrar/templates/django/admin/includes/descriptions/user_domain_role_description.html @@ -1,10 +1,13 @@

-This table represents the managers who are assigned to each domain in the registrar. -There are separate records for each domain/manager combination. -Managers can update information related to a domain, such as DNS data and security contact. + This table represents the managers who are assigned to each domain in the registrar. There are separate records for each domain/manager combination. + Managers can update information related to a domain, such as DNS data and security contact.

-The creator of an approved domain request automatically becomes a manager for that domain. -Anyone who retrieves a domain invitation is also assigned the manager role. + The creator of an approved domain request automatically becomes a manager for that domain. + Anyone who retrieves a domain invitation will also appear in this table as a manager. +

+ +

+ If you add or remove someone to a domain by using this table, those actions won’t trigger notification emails.

diff --git a/src/registrar/templates/django/admin/includes/descriptions/user_portfolio_permission_description.html b/src/registrar/templates/django/admin/includes/descriptions/user_portfolio_permission_description.html new file mode 100644 index 000000000..fd8919b8f --- /dev/null +++ b/src/registrar/templates/django/admin/includes/descriptions/user_portfolio_permission_description.html @@ -0,0 +1,11 @@ +

+ This table represents the members of each portfolio in the registrar. There are separate records for each member/portfolio combination. +

+ +

+ Each member is assigned one of two access levels: admin or basic. Only admins can manage member permissions and organization metadata. +

+ +

+ If you add or remove someone to a portfolio by using this table, those actions won’t trigger notification emails. +

\ No newline at end of file diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index a074e8a7c..f12bd67f9 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -160,6 +160,16 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% endblock field_readonly %} {% block field_other %} + {% comment %} + .gov override - add Aria messages for select2 dropdowns. These messages are hooked-up to their respective DOM + elements via javascript (see andi.js) + {% endcomment %} + {% if "related_widget_wrapper" in field.field.field.widget.template_name %} + + {{ field.field.label }}, edit, has autocomplete. To set the value, use the arrow keys or type the text. + + {% endif %} + {% if field.field.name == "action_needed_reason_email" %} {{ field.field }} @@ -251,7 +261,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) {% elif field.field.name == "rejection_reason_email" %} {{ field.field }} - @@ -331,7 +340,6 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) - {% if original_object.rejection_reason_email %} {% else %} diff --git a/src/registrar/templates/django/admin/multiple_choice_list_filter.html b/src/registrar/templates/django/admin/multiple_choice_list_filter.html index c64fa1be1..27b8d9969 100644 --- a/src/registrar/templates/django/admin/multiple_choice_list_filter.html +++ b/src/registrar/templates/django/admin/multiple_choice_list_filter.html @@ -9,16 +9,12 @@ {% for choice in choices %} {% if choice.reset %} - {{ choice.display }} + {{ choice.display }} - {% endif %} - {% endfor %} - - {% for choice in choices %} - {% if not choice.reset %} - + {% else %} + {% if choice.selected and choice.exclude_query_string %} - {{ choice.display }} + {{ choice.display }} @@ -26,9 +22,8 @@ - {% endif %} - {% if not choice.selected and choice.include_query_string %} - {{ choice.display }} + {% elif not choice.selected and choice.include_query_string %} + {{ choice.display }} @@ -38,4 +33,4 @@ {% endif %} {% endfor %} - + \ No newline at end of file diff --git a/src/registrar/templates/django/admin/portfolio_invitation_change_form.html b/src/registrar/templates/django/admin/portfolio_invitation_change_form.html index 959e8f8bf..c679b36cd 100644 --- a/src/registrar/templates/django/admin/portfolio_invitation_change_form.html +++ b/src/registrar/templates/django/admin/portfolio_invitation_change_form.html @@ -6,7 +6,11 @@

- If you add someone to a portfolio here, it will trigger an invitation email when you click "save." If you don't want to trigger an email, use the User portfolio permissions table instead. + If you invite someone to a portfolio here, it will trigger email notifications. If you don't want to trigger emails, use the + + User Portfolio Permissions + + table instead.

diff --git a/src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html b/src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html index 932822766..187b5d2c7 100644 --- a/src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html +++ b/src/registrar/templates/django/admin/portfolio_invitation_delete_confirmation.html @@ -4,12 +4,12 @@

- If you cancel the portfolio invitation here, it won't trigger any emails. It also won't remove the user's - portfolio access if they already logged in. Go to the + If you cancel the portfolio invitation here, it won't trigger any email notifications. + It also won't remove the user's portfolio access if they already logged in. Go to the User Portfolio Permissions - table if you want to remove the user from a portfolio. + table if you want to remove their portfolio access.

diff --git a/src/registrar/templates/django/admin/portfolio_invitation_delete_selected_confirmation.html b/src/registrar/templates/django/admin/portfolio_invitation_delete_selected_confirmation.html new file mode 100644 index 000000000..2d8bed70b --- /dev/null +++ b/src/registrar/templates/django/admin/portfolio_invitation_delete_selected_confirmation.html @@ -0,0 +1,17 @@ +{% extends "admin/delete_selected_confirmation.html" %} + +{% block content_subtitle %} +
+
+

+ If you cancel the portfolio invitation here, it won't trigger any email notifications. + It also won't remove the user's portfolio access if they already logged in. Go to the + + User Portfolio Permissions + + table if you want to remove their portfolio access. +

+
+
+ {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/django/admin/user_domain_role_change_form.html b/src/registrar/templates/django/admin/user_domain_role_change_form.html index d8c298bc1..5c589952f 100644 --- a/src/registrar/templates/django/admin/user_domain_role_change_form.html +++ b/src/registrar/templates/django/admin/user_domain_role_change_form.html @@ -6,7 +6,10 @@

- If you add someone to a domain here, it will not trigger any emails. To trigger emails, use the User Domain Role invitations table instead. + If you add someone to a domain here, it won't trigger any email notifications. To trigger emails, use the + + Domain Invitations + table instead.

diff --git a/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html b/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html index 171f4c3ea..68ae1765d 100644 --- a/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html +++ b/src/registrar/templates/django/admin/user_domain_role_delete_confirmation.html @@ -5,7 +5,7 @@ diff --git a/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html b/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html index 392d1aebc..c06a5493e 100644 --- a/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html +++ b/src/registrar/templates/django/admin/user_domain_role_delete_selected_confirmation.html @@ -5,7 +5,7 @@ diff --git a/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html b/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html index 489d67bc5..76e37d568 100644 --- a/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html +++ b/src/registrar/templates/django/admin/user_portfolio_permission_change_form.html @@ -6,7 +6,11 @@

- If you add someone to a portfolio here, it will not trigger an invitation email. To trigger an email, use the Portfolio invitations table instead. + If you add someone to a portfolio here, it won't trigger any email notifications. To trigger emails, use the + + Portfolio Invitations + + table instead.

diff --git a/src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html b/src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html index 71c789a63..eb2dd078a 100644 --- a/src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html +++ b/src/registrar/templates/django/admin/user_portfolio_permission_delete_confirmation.html @@ -4,7 +4,7 @@

- If you remove someone from a portfolio here, it will not send any emails when you click "Save". + If you remove someone from a portfolio here, it won't trigger any email notifications.

diff --git a/src/registrar/templates/django/admin/user_portfolio_permission_delete_selected_confirmation.html b/src/registrar/templates/django/admin/user_portfolio_permission_delete_selected_confirmation.html new file mode 100644 index 000000000..d0d586f07 --- /dev/null +++ b/src/registrar/templates/django/admin/user_portfolio_permission_delete_selected_confirmation.html @@ -0,0 +1,12 @@ +{% extends "admin/delete_selected_confirmation.html" %} + +{% block content_subtitle %} +
+
+

+ If you remove someone from a portfolio here, it won't trigger any email notifications. +

+
+
+ {{ block.super }} +{% endblock %} diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index 58038d0a4..249f69d32 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -46,7 +46,7 @@ {# messages block is under the back breadcrumb link #} {% if messages %} {% for message in messages %} -
+