diff --git a/src/registrar/admin.py b/src/registrar/admin.py index e7030c2d3..6b5cbf002 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -117,6 +117,15 @@ class ListHeaderAdmin(AuditedAdmin): ) return filters + # customize the help_text for all formfields for manytomany + def formfield_for_manytomany(self, db_field, request, **kwargs): + formfield = super().formfield_for_manytomany(db_field, request, **kwargs) + formfield.help_text = ( + formfield.help_text + + " If more than one value is selected, the change/delete/view actions will be disabled." + ) + return formfield + class UserContactInline(admin.StackedInline): """Edit a user's profile on the user page.""" @@ -464,6 +473,17 @@ class DomainInformationAdmin(ListHeaderAdmin): "is_policy_acknowledged", ] + # For each filter_horizontal, init in admin js extendFilterHorizontalWidgets + # to activate the edit/delete/view buttons + filter_horizontal = ("other_contacts",) + + # lists in filter_horizontal are not sorted properly, sort them + # by first_name + def formfield_for_manytomany(self, db_field, request, **kwargs): + if db_field.name in ("other_contacts",): + kwargs["queryset"] = models.Contact.objects.all().order_by("first_name") # Sort contacts + return super().formfield_for_manytomany(db_field, request, **kwargs) + def get_readonly_fields(self, request, obj=None): """Set the read-only state on form elements. We have 1 conditions that determine which fields are read-only: @@ -600,6 +620,15 @@ class DomainApplicationAdmin(ListHeaderAdmin): "is_policy_acknowledged", ] + filter_horizontal = ("current_websites", "alternative_domains", "other_contacts") + + # lists in filter_horizontal are not sorted properly, sort them + # by website + def formfield_for_manytomany(self, db_field, request, **kwargs): + if db_field.name in ("current_websites", "alternative_domains"): + kwargs["queryset"] = models.Website.objects.all().order_by("website") # Sort websites + return super().formfield_for_manytomany(db_field, request, **kwargs) + # Trigger action when a fieldset is changed def save_model(self, request, obj, form, change): if obj and obj.creator.status != models.User.RESTRICTED: @@ -728,6 +757,16 @@ class DomainInformationInline(admin.StackedInline): fieldsets = DomainInformationAdmin.fieldsets analyst_readonly_fields = DomainInformationAdmin.analyst_readonly_fields + # For each filter_horizontal, init in admin js extendFilterHorizontalWidgets + # to activate the edit/delete/view buttons + filter_horizontal = ("other_contacts",) + + # lists in filter_horizontal are not sorted properly, sort them + # by first_name + def formfield_for_manytomany(self, db_field, request, **kwargs): + if db_field.name in ("other_contacts",): + kwargs["queryset"] = models.Contact.objects.all().order_by("first_name") # Sort contacts + return super().formfield_for_manytomany(db_field, request, **kwargs) def get_readonly_fields(self, request, obj=None): return DomainInformationAdmin.get_readonly_fields(self, request, obj=None) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index 3b9f19a49..53eeb22a3 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -47,4 +47,231 @@ function openInNewTab(el, removeAttribute = false){ } prepareDjangoAdmin(); -})(); \ No newline at end of file +})(); + +/** + * An IIFE to listen to changes on filter_horizontal and enable or disable the change/delete/view buttons as applicable + * + */ +(function extendFilterHorizontalWidgets() { + // Initialize custom filter_horizontal widgets; each widget has a "from" select list + // and a "to" select list; initialization is based off of the presence of the + // "to" select list + checkToListThenInitWidget('id_other_contacts_to', 0); + checkToListThenInitWidget('id_domain_info-0-other_contacts_to', 0); + checkToListThenInitWidget('id_current_websites_to', 0); + checkToListThenInitWidget('id_alternative_domains_to', 0); +})(); + +// Function to check for the existence of the "to" select list element in the DOM, and if and when found, +// initialize the associated widget +function checkToListThenInitWidget(toListId, attempts) { + let toList = document.getElementById(toListId); + attempts++; + + if (attempts < 6) { + if ((toList !== null)) { + // toList found, handle it + // Add an event listener on the element + // Add disabled buttons on the element's great-grandparent + initializeWidgetOnToList(toList, toListId); + } else { + // Element not found, check again after a delay + setTimeout(() => checkToListThenInitWidget(toListId, attempts), 1000); // Check every 1000 milliseconds (1 second) + } + } +} + +// Initialize the widget: +// add related buttons to the widget for edit, delete and view +// add event listeners on the from list, the to list, and selector buttons which either enable or disable the related buttons +function initializeWidgetOnToList(toList, toListId) { + // create the change button + let changeLink = createAndCustomizeLink( + toList, + toListId, + 'related-widget-wrapper-link change-related', + 'Change', + '/public/admin/img/icon-changelink.svg', + { + 'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id&_popup=1', + 'websites': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1', + 'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1', + }, + true, + true + ); + + let hasDeletePermission = hasDeletePermissionOnPage(); + + let deleteLink = null; + if (hasDeletePermission) { + // create the delete button if user has permission to delete + deleteLink = createAndCustomizeLink( + toList, + toListId, + 'related-widget-wrapper-link delete-related', + 'Delete', + '/public/admin/img/icon-deletelink.svg', + { + 'contacts': '/admin/registrar/contact/__fk__/delete/?_to_field=id&_popup=1', + 'websites': '/admin/registrar/website/__fk__/delete/?_to_field=id&_popup=1', + 'alternative_domains': '/admin/registrar/website/__fk__/delete/?_to_field=id&_popup=1', + }, + true, + false + ); + } + + // create the view button + let viewLink = createAndCustomizeLink( + toList, + toListId, + 'related-widget-wrapper-link view-related', + 'View', + '/public/admin/img/icon-viewlink.svg', + { + 'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id', + 'websites': '/admin/registrar/website/__fk__/change/?_to_field=id', + 'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id', + }, + false, + false + ); + + // identify the fromList element in the DOM + let fromList = toList.closest('.selector').querySelector(".selector-available select"); + + fromList.addEventListener('click', function(event) { + handleSelectClick(fromList, changeLink, deleteLink, viewLink); + }); + + toList.addEventListener('click', function(event) { + handleSelectClick(toList, changeLink, deleteLink, viewLink); + }); + + // Disable buttons when the selectors are interacted with (items are moved from one column to the other) + let selectorButtons = []; + selectorButtons.push(toList.closest(".selector").querySelector(".selector-chooseall")); + selectorButtons.push(toList.closest(".selector").querySelector(".selector-add")); + selectorButtons.push(toList.closest(".selector").querySelector(".selector-remove")); + + selectorButtons.forEach((selector) => { + selector.addEventListener("click", ()=>{disableRelatedWidgetButtons(changeLink, deleteLink, viewLink)}); + }); +} + +// create and customize the button, then add to the DOM, relative to the toList +// toList - the element in the DOM for the toList +// toListId - the ID of the element in the DOM +// className - className to add to the created link +// action - the action to perform on the item {change, delete, view} +// imgSrc - the img.src for the created link +// dataMappings - dictionary which relates toListId to href for the created link +// dataPopup - boolean for whether the link should produce a popup window +// firstPosition - boolean indicating if link should be first position in list of links, otherwise, should be last link +function createAndCustomizeLink(toList, toListId, className, action, imgSrc, dataMappings, dataPopup, firstPosition) { + // Create a link element + var link = document.createElement('a'); + + // Set class attribute for the link + link.className = className; + + // Set id + // Determine function {change, link, view} from the className + // Add {function}_ to the beginning of the string + let modifiedLinkString = className.split('-')[0] + '_' + toListId; + // Remove '_to' from the end of the string + modifiedLinkString = modifiedLinkString.replace('_to', ''); + link.id = modifiedLinkString; + + // Set data-href-template + for (const [idPattern, template] of Object.entries(dataMappings)) { + if (toListId.includes(idPattern)) { + link.setAttribute('data-href-template', template); + break; // Stop checking once a match is found + } + } + + if (dataPopup) + link.setAttribute('data-popup', 'yes'); + + link.setAttribute('title-template', action + " selected item") + link.title = link.getAttribute('title-template'); + + // Create an 'img' element + var img = document.createElement('img'); + + // Set attributes for the new image + img.src = imgSrc; + img.alt = action; + + // Append the image to the link + link.appendChild(img); + + let relatedWidgetWrapper = toList.closest('.related-widget-wrapper'); + // If firstPosition is true, insert link as the first child element + if (firstPosition) { + relatedWidgetWrapper.insertBefore(link, relatedWidgetWrapper.children[0]); + } else { + // otherwise, insert the link prior to the last child (which is a div) + // and also prior to any text elements immediately preceding the last + // child node + var lastChild = relatedWidgetWrapper.lastChild; + + // Check if lastChild is an element node (not a text node, comment, etc.) + if (lastChild.nodeType === 1) { + var previousSibling = lastChild.previousSibling; + // need to work around some white space which has been inserted into the dom + while (previousSibling.nodeType !== 1) { + previousSibling = previousSibling.previousSibling; + } + relatedWidgetWrapper.insertBefore(link, previousSibling.nextSibling); + } + } + + // Return the link, which we'll use in the disable and enable functions + return link; +} + +// Either enable or disable widget buttons when select is clicked. Action (enable or disable) taken depends on the count +// of selected items in selectElement. If exactly one item is selected, buttons are enabled, and urls for the buttons are +// associated with the selected item +function handleSelectClick(selectElement, changeLink, deleteLink, viewLink) { + + // If one item is selected (across selectElement and relatedSelectElement), enable buttons; otherwise, disable them + if (selectElement.selectedOptions.length === 1) { + // enable buttons for selected item in selectElement + enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, selectElement.selectedOptions[0].value, selectElement.selectedOptions[0].text); + } else { + disableRelatedWidgetButtons(changeLink, deleteLink, viewLink); + } +} + +// return true if there exist elements on the page with classname of delete-related. +// presence of one or more of these elements indicates user has permission to delete +function hasDeletePermissionOnPage() { + return document.querySelector('.delete-related') != null +} + +function disableRelatedWidgetButtons(changeLink, deleteLink, viewLink) { + changeLink.removeAttribute('href'); + changeLink.setAttribute('title', changeLink.getAttribute('title-template')); + if (deleteLink) { + deleteLink.removeAttribute('href'); + deleteLink.setAttribute('title', deleteLink.getAttribute('title-template')); + } + viewLink.removeAttribute('href'); + viewLink.setAttribute('title', viewLink.getAttribute('title-template')); +} + +function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, elementText) { + changeLink.setAttribute('href', changeLink.getAttribute('data-href-template').replace('__fk__', elementPk)); + changeLink.setAttribute('title', changeLink.getAttribute('title-template').replace('selected item', elementText)); + if (deleteLink) { + deleteLink.setAttribute('href', deleteLink.getAttribute('data-href-template').replace('__fk__', elementPk)); + deleteLink.setAttribute('title', deleteLink.getAttribute('title-template').replace('selected item', elementText)); + } + viewLink.setAttribute('href', viewLink.getAttribute('data-href-template').replace('__fk__', elementPk)); + viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText)); +} diff --git a/src/registrar/migrations/0049_alter_domainapplication_current_websites_and_more.py b/src/registrar/migrations/0049_alter_domainapplication_current_websites_and_more.py new file mode 100644 index 000000000..4341bdad6 --- /dev/null +++ b/src/registrar/migrations/0049_alter_domainapplication_current_websites_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.7 on 2023-12-05 10:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0048_alter_transitiondomain_status"), + ] + + operations = [ + migrations.AlterField( + model_name="domainapplication", + name="current_websites", + field=models.ManyToManyField( + blank=True, related_name="current+", to="registrar.website", verbose_name="websites" + ), + ), + migrations.AlterField( + model_name="domainapplication", + name="other_contacts", + field=models.ManyToManyField( + blank=True, related_name="contact_applications", to="registrar.contact", verbose_name="contacts" + ), + ), + migrations.AlterField( + model_name="domaininformation", + name="other_contacts", + field=models.ManyToManyField( + blank=True, + related_name="contact_applications_information", + to="registrar.contact", + verbose_name="contacts", + ), + ), + ] diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 86b8a0f7a..9ab3908d4 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -487,6 +487,7 @@ class DomainApplication(TimeStampedModel): "registrar.Website", blank=True, related_name="current+", + verbose_name="websites", ) approved_domain = models.OneToOneField( @@ -532,6 +533,7 @@ class DomainApplication(TimeStampedModel): "registrar.Contact", blank=True, related_name="contact_applications", + verbose_name="contacts", ) no_other_contacts_rationale = models.TextField( diff --git a/src/registrar/models/domain_information.py b/src/registrar/models/domain_information.py index d2bc5c53d..9f0d654b0 100644 --- a/src/registrar/models/domain_information.py +++ b/src/registrar/models/domain_information.py @@ -179,6 +179,7 @@ class DomainInformation(TimeStampedModel): "registrar.Contact", blank=True, related_name="contact_applications_information", + verbose_name="contacts", ) no_other_contacts_rationale = models.TextField( diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index dcdd29e2f..c0884c912 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -18,6 +18,7 @@ + {% endblock %} {% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index 6c401ad72..c4461d07f 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -1,11 +1,6 @@ {% extends 'admin/change_form.html' %} {% load i18n static %} -{% block extrahead %} -{{ block.super }} - -{% endblock %} - {% block field_sets %}