Merge branch 'za/1852-user-contact-info-inline' into za/1848-copy-contact-email-to-clipboard

This commit is contained in:
zandercymatics 2024-03-20 14:39:02 -06:00
commit e7edf19897
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
11 changed files with 459 additions and 183 deletions

View file

@ -229,6 +229,32 @@ function checkToListThenInitWidget(toListId, attempts) {
}
}
(function() {
function addUrlsToOtherContacts() {
document.addEventListener('DOMContentLoaded', function() {
// Select all table rows that have a data-contact-id attribute
let contactRows = document.querySelectorAll("tr[data-contact-url]");
if (contactRows){
// Add a click event listener to each row
let index = 1;
contactRows.forEach(function(row) {
let otherContactUrl = row.getAttribute("data-contact-url");
if (otherContactUrl){
let otherContact = document.querySelector(`.other-contact__${index}`);
if (otherContact) {
otherContact.href = otherContactUrl;
}
}
index++;
});
}
});
}
addUrlsToOtherContacts()
})();
// 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

View file

@ -54,64 +54,78 @@
})();
document.addEventListener("DOMContentLoaded", function () {
createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date");
createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date");
createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date");
createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date");
createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date");
createComparativeColumnChart("myChart6", "All requests", "Start Date", "End Date");
});
function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) {
var canvas = document.getElementById(canvasId);
var ctx = canvas.getContext("2d");
/** An IIFE to initialize the analytics page
*/
(function () {
function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) {
var canvas = document.getElementById(canvasId);
if (!canvas) {
console.log("Could not find canvas")
return
}
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
var ctx = canvas.getContext("2d");
var data = {
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
datasets: [
{
label: labelOne,
backgroundColor: "rgba(255, 99, 132, 0.2)",
borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1,
data: listOne,
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
var data = {
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
datasets: [
{
label: labelOne,
backgroundColor: "rgba(255, 99, 132, 0.2)",
borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1,
data: listOne,
},
{
label: labelTwo,
backgroundColor: "rgba(75, 192, 192, 0.2)",
borderColor: "rgba(75, 192, 192, 1)",
borderWidth: 1,
data: listTwo,
},
],
};
var options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: title
}
},
{
label: labelTwo,
backgroundColor: "rgba(75, 192, 192, 0.2)",
borderColor: "rgba(75, 192, 192, 1)",
borderWidth: 1,
data: listTwo,
scales: {
y: {
beginAtZero: true,
},
},
],
};
new Chart(ctx, {
type: "bar",
data: data,
options: options,
});
}
function initComparativeColumnCharts() {
document.addEventListener("DOMContentLoaded", function () {
createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date");
createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date");
createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date");
createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date");
createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date");
createComparativeColumnChart("myChart6", "All requests", "Start Date", "End Date");
});
};
var options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: title
}
},
scales: {
y: {
beginAtZero: true,
},
},
};
new Chart(ctx, {
type: "bar",
data: data,
options: options,
});
}
initComparativeColumnCharts();
})();

View file

@ -361,6 +361,13 @@ details.dja-detail-table {
}
}
table.dja-user-detail-table {
margin-left: 160px;
tr {
background-color: var(--body-bg);
}
}
.admin-icon-group {
position: relative;
display: flex;

View file

@ -46,4 +46,4 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
</div>
{% endfor %}
{% endblock fieldset_lines %}
</fieldset>
</fieldset>

View file

@ -1,96 +0,0 @@
{% extends 'admin/change_form.html' %}
{% load i18n static %}
{% block field_sets %}
{# Create an invisible <a> tag so that we can use a click event to toggle the modal. #}
<a id="invisible-ineligible-modal-toggler" class="display-none" href="#toggle-set-ineligible" aria-controls="toggle-set-ineligible" data-open-modal></a>
{{ block.super }}
{% endblock %}
{% block submit_buttons_bottom %}
{% comment %}
Modals behave very weirdly in django admin.
They tend to "strip out" any injected form elements, leaving only the main form.
In addition, USWDS handles modals by first destroying the element, then repopulating it toward the end of the page.
In effect, this means that the modal is not, and cannot, be surrounded by any form element at compile time.
The current workaround for this is to use javascript to inject a hidden input, and bind submit of that
element to the click of the confirmation button within this modal.
This is controlled by the class `dja-form-placeholder` on the button.
In addition, the modal element MUST be placed low in the DOM. The script loads slower on DJA than on other portions
of the application, so this means that it will briefly "populate", causing unintended visual effects.
{% endcomment %}
{# Create a modal for when a domain is marked as ineligible #}
<div
class="usa-modal"
id="toggle-set-ineligible"
aria-labelledby="Are you sure you want to select ineligible status?"
aria-describedby="This request will be marked as ineligible."
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
Are you sure you want to select ineligible status?
</h2>
<div class="usa-prose">
<p>
When a domain request is in ineligible status, the registrant's permissions within the registrar are restricted as follows:
</p>
<ul>
<li class="font-body-sm">They cannot edit the ineligible request or any other pending requests.</li>
<li class="font-body-sm">They cannot manage any of their approved domains.</li>
<li class="font-body-sm">They cannot initiate a new domain request.</li>
</ul>
<p>
The restrictions will not take effect until you “save” the changes for this domain request.
This action can be reversed, if needed.
</p>
<p>
Domain: <b>{{ original.requested_domain.name }}</b>
{# Acts as a <br> #}
<div class="display-inline"></div>
New status: <b>{{ original.DomainRequestStatus.INELIGIBLE|capfirst }}</b>
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
class="usa-button"
name="_set_domain_request_ineligible"
data-close-modal
>
Yes, select ineligible status
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
name="_cancel_domain_request_ineligible"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -2,7 +2,97 @@
{% load i18n static %}
{% block field_sets %}
{# Create an invisible <a> tag so that we can use a click event to toggle the modal. #}
<a id="invisible-ineligible-modal-toggler" class="display-none" href="#toggle-set-ineligible" aria-controls="toggle-set-ineligible" data-open-modal></a>
{% for fieldset in adminform %}
{% include "django/admin/includes/domain_request_fieldset.html" %}
{% endfor %}
{% endblock %}
{% block submit_buttons_bottom %}
{% comment %}
Modals behave very weirdly in django admin.
They tend to "strip out" any injected form elements, leaving only the main form.
In addition, USWDS handles modals by first destroying the element, then repopulating it toward the end of the page.
In effect, this means that the modal is not, and cannot, be surrounded by any form element at compile time.
The current workaround for this is to use javascript to inject a hidden input, and bind submit of that
element to the click of the confirmation button within this modal.
This is controlled by the class `dja-form-placeholder` on the button.
In addition, the modal element MUST be placed low in the DOM. The script loads slower on DJA than on other portions
of the application, so this means that it will briefly "populate", causing unintended visual effects.
{% endcomment %}
{# Create a modal for when a domain is marked as ineligible #}
<div
class="usa-modal"
id="toggle-set-ineligible"
aria-labelledby="Are you sure you want to select ineligible status?"
aria-describedby="This request will be marked as ineligible."
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="modal-1-heading">
Are you sure you want to select ineligible status?
</h2>
<div class="usa-prose">
<p>
When a domain request is in ineligible status, the registrant's permissions within the registrar are restricted as follows:
</p>
<ul>
<li class="font-body-sm">They cannot edit the ineligible request or any other pending requests.</li>
<li class="font-body-sm">They cannot manage any of their approved domains.</li>
<li class="font-body-sm">They cannot initiate a new domain request.</li>
</ul>
<p>
The restrictions will not take effect until you “save” the changes for this domain request.
This action can be reversed, if needed.
</p>
<p>
Domain: <b>{{ original.requested_domain.name }}</b>
{# Acts as a <br> #}
<div class="display-inline"></div>
New status: <b>{{ original.DomainRequestStatus.INELIGIBLE|capfirst }}</b>
</p>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button
type="submit"
class="usa-button"
name="_set_domain_request_ineligible"
data-close-modal
>
Yes, select ineligible status
</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled padding-105 text-center"
name="_cancel_domain_request_ineligible"
data-close-modal
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
</button>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -1,28 +1,42 @@
{% load i18n static %}
<details class="margin-top-1 dja-detail-table" {% if start_opened %}open{% endif %}>
<summary class="padding-1 dja-details-summary">Details</summary>
<div class="grid-container padding-left-0 padding-right-0 dja-details-contents">
{% if user.title or user.email or user.phone %}
<table>
<tbody>
<tr>
<th scope="row">Title</th>
<td>{{ user.title }}</td>
</tr>
<tr>
<th scope="row">Email</th>
<td>{{ user.email }}</td>
</tr>
<tr>
<th scope="row">Phone</th>
<td>{{ user.phone }}</td>
</tr>
</tbody>
</table>
{% else %}
<div class="padding-1">No details found</div>
{% endif %}
</div>
</details>
<table class="dja-user-detail-table" {% if field_name %} id="id_detail_table__{{field_name}}" {% endif %}>
<tbody>
<tr>
<th class="padding-left-0" scope="row">Title</th>
{% if user.title or user.contact.title %}
{% if user.contact.title %}
<td>{{ user.contact.title }}</td>
{% else %}
<td>{{ user.title }}</td>
{% endif %}
{% else %}
<td>Nothing found</td>
{% endif %}
</tr>
<tr>
<th class="padding-left-0" scope="row">Email</th>
{% if user.email or user.contact.email %}
{% if user.contact.email %}
<td>{{ user.contact.email }}</td>
{% else %}
<td>{{ user.email }}</td>
{% endif %}
{% else %}
<td>Nothing found</td>
{% endif %}
</tr>
<tr>
<th class="padding-left-0" scope="row">Phone</th>
{% if user.phone or user.contact.phone %}
{% if user.contact.phone %}
<td>{{ user.contact.phone }}</td>
{% else %}
<td>{{ user.phone }}</td>
{% endif %}
{% else %}
<td>Nothing found</td>
{% endif %}
</tr>
</tbody>
</table>

View file

@ -1,5 +1,5 @@
{% extends "admin/fieldset.html" %}
{% load static url_helpers %}
{% comment %}
This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endcomment %}
@ -16,18 +16,38 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% else %}
{{ field.label_tag }}
{% if field.is_readonly %}
<div class="readonly">{{ field.contents }}</div>
{% if field.field.name == "other_contacts" %}
<div class="readonly">
{% for contact in field.contents|split:", " %}
<a href="#" class="other-contact__{{forloop.counter}}">{{ contact }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% elif field.field.name == "current_websites" %}
{% comment %}
The "website" model is essentially just a text field.
It is not useful to be redirected to the object definition,
rather it is more useful in this scenario to be redirected to the
actual website (as its just a plaintext string otherwise).
This ONLY applies to analysts. For superusers, its business as usual.
{% endcomment %}
{% for website in field.contents|split:", " %}
<a href="{{ website }}" class="padding-top-1 current-website__{{forloop.counter}}">{{ website }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% else %}
<div class="readonly">{{ field.contents }}</div>
{% endif %}
{% else %}
{{ field.field }}
{% endif %}
{% endif %}
</div>
{% if field.field.name == "creator" %}
{% include "django/admin/includes/domain_request_detail_table.html" with user=original.creator start_opened=True %}
{% include "django/admin/includes/domain_request_detail_table.html" with user=original.creator field_name="creator" %}
{% elif field.field.name == "submitter" %}
{% include "django/admin/includes/domain_request_detail_table.html" with user=original.submitter start_opened=True %}
{% include "django/admin/includes/domain_request_detail_table.html" with user=original.submitter field_name="submitter" %}
{% elif field.field.name == "authorizing_official" %}
{% include "django/admin/includes/domain_request_detail_table.html" with user=original.authorizing_official start_opened=True %}
{% include "django/admin/includes/domain_request_detail_table.html" with user=original.authorizing_official field_name="authorizing_official" %}
{% elif field.field.name == "other_contacts" %}
<details class="margin-top-1 dja-detail-table">
<summary class="padding-1 dja-details-summary">Details</summary>
@ -35,7 +55,11 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<table>
<tbody>
{% for contact in original.other_contacts.all %}
<tr>
{% comment %}
Since we can't get the id from field, we can embed this information here.
Then we can link these two fields using javascript.
{% endcomment %}
<tr data-contact-id="{{ contact.id }}" data-contact-url="{% url 'admin:registrar_contact_change' contact.id %}">
<th scope="row">{{contact.first_name}} {{contact.last_name}}</th>
<td>{{ contact.title }}</td>
<td>{{ contact.email }}</td>

View file

@ -26,6 +26,14 @@ def endswith(text, ends):
return False
@register.filter("split")
def split_string(value, key):
"""
Splits a given string
"""
return value.split(key)
@register.simple_tag
def public_site_url(url_path):
"""Make a full URL for this path at our public site.

View file

@ -2,6 +2,7 @@ from datetime import date
from django.test import TestCase, RequestFactory, Client, override_settings
from django.contrib.admin.sites import AdminSite
from contextlib import ExitStack
from api.tests.common import less_console_noise_decorator
from django_webtest import WebTest # type: ignore
from django.contrib import messages
from django.urls import reverse
@ -1193,6 +1194,182 @@ class TestDomainRequestAdmin(MockEppLib):
# Test that approved domain exists and equals requested domain
self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name)
@less_console_noise_decorator
def test_other_contacts_has_readonly_link(self):
"""Tests if the readonly other_contacts field has links"""
# Create a fake domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
# Get the other contact
other_contact = domain_request.other_contacts.all().first()
p = "userpass"
self.client.login(username="staffuser", password=p)
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
follow=True,
)
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name)
# Check that the page contains the url we expect
expected_href = reverse("admin:registrar_contact_change", args=[other_contact.id])
self.assertContains(response, expected_href)
# Check that the page contains the link we expect.
# Since the url is dynamic (populated by JS), we can test for its existence
# by checking for the end tag.
expected_url = "Testy Tester</a>"
self.assertContains(response, expected_url)
@less_console_noise_decorator
def test_other_websites_has_readonly_link(self):
"""Tests if the readonly other_websites field has links"""
# Create a fake domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
p = "userpass"
self.client.login(username="staffuser", password=p)
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
follow=True,
)
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name)
# Check that the page contains the link we expect.
expected_url = '<a href="city.com" class="padding-top-1 current-website__1">city.com</a>'
self.assertContains(response, expected_url)
def assert_response_contains_distinct_values(self, response, expected_values):
"""
Asserts that each specified value appears exactly once in the response.
This method iterates over a list of tuples, where each tuple contains a field name
and its expected value. It then performs an assertContains check for each value,
ensuring that each value appears exactly once in the response.
Parameters:
- response: The HttpResponse object to inspect.
- expected_values: A list of tuples, where each tuple contains:
- field: The name of the field (used for subTest identification).
- value: The expected value to check for in the response.
Example usage:
expected_values = [
("title", "Treat inspector</td>"),
("email", "meoward.jones@igorville.gov</td>"),
]
self.assert_response_contains_distinct_values(response, expected_values)
"""
for field, value in expected_values:
with self.subTest(field=field, expected_value=value):
self.assertContains(response, value, count=1)
@less_console_noise_decorator
def test_contact_fields_have_detail_table(self):
"""Tests if the contact fields have the detail table which displays title, email, and phone"""
# Create fake creator
_creator = User.objects.create(
username="MrMeoward",
first_name="Meoward",
last_name="Jones",
)
# Due to the relation between User <==> Contact,
# the underlying contact has to be modified this way.
_creator.contact.email = "meoward.jones@igorville.gov"
_creator.contact.phone = "(555) 123 12345"
_creator.contact.title = "Treat inspector"
_creator.contact.save()
# Create a fake domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW, user=_creator)
p = "userpass"
self.client.login(username="staffuser", password=p)
response = self.client.get(
"/admin/registrar/domainrequest/{}/change/".format(domain_request.pk),
follow=True,
)
# Make sure the page loaded, and that we're on the right page
self.assertEqual(response.status_code, 200)
self.assertContains(response, domain_request.requested_domain.name)
# Check that the modal has the right content
# Check for the header
# == Check for the creator == #
# Check for the right title, email, and phone number in the response.
# We only need to check for the end tag
# (Otherwise this test will fail if we change classes, etc)
expected_creator_fields = [
# Field, expected value
("title", "Treat inspector</td>"),
("email", "meoward.jones@igorville.gov</td>"),
("phone", "(555) 123 12345</td>"),
]
self.assert_response_contains_distinct_values(response, expected_creator_fields)
# Check for the field itself
self.assertContains(response, "Meoward Jones")
# == Check for the submitter == #
expected_submitter_fields = [
# Field, expected value
("title", "Admin Tester</td>"),
("email", "mayor@igorville.gov</td>"),
("phone", "(555) 555 5556</td>"),
]
self.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == #
expected_ao_fields = [
# Field, expected value
("title", "Chief Tester</td>"),
("email", "testy@town.com</td>"),
("phone", "(555) 555 5555</td>"),
]
self.assert_response_contains_distinct_values(response, expected_ao_fields)
# count=4 because the underlying domain has two users with this name.
# The dropdown has 3 of these.
self.assertContains(response, "Testy Tester", count=4)
# Check for table titles. We only need to check for the end tag
# (Otherwise this test will fail if we change classes, etc)
# Title. Count=3 because this table appears on three records.
self.assertContains(response, "Title</th>", count=3)
# Email. Count=3 because this table appears on three records.
self.assertContains(response, "Email</th>", count=3)
# Phone. Count=3 because this table appears on three records.
self.assertContains(response, "Phone</th>", count=3)
# == Test the other_employees field == #
expected_other_employees_fields = [
# Field, expected value
("title", "Another Tester</td>"),
("email", "testy2@town.com</td>"),
("phone", "(555) 555 5557</td>"),
]
self.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# count=1 as only one should exist in a table
self.assertContains(response, "Testy Tester</th>", count=1)
def test_save_model_sets_restricted_status_on_user(self):
with less_console_noise():
# make sure there is no user with this email
@ -1340,6 +1517,8 @@ class TestDomainRequestAdmin(MockEppLib):
"requested_domain",
"approved_domain",
"alternative_domains",
"other_contacts",
"current_websites",
"purpose",
"submitter",
"no_other_contacts_rationale",