Merge pull request #1913 from cisagov/za/1848-copy-contact-email-to-clipboard

Ticket #1848: Add copy email button to all email fields in Django Admin
This commit is contained in:
zandercymatics 2024-04-08 08:41:21 -06:00 committed by GitHub
commit 3302dbab70
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 295 additions and 35 deletions

View file

@ -562,6 +562,8 @@ class MyUserAdmin(BaseUserAdmin):
# in autocomplete_fields for user
ordering = ["first_name", "last_name", "email"]
change_form_template = "django/admin/email_clipboard_change_form.html"
def get_search_results(self, request, queryset, search_term):
"""
Override for get_search_results. This affects any upstream model using autocomplete_fields,
@ -666,6 +668,17 @@ class ContactAdmin(ListHeaderAdmin):
# in autocomplete_fields for user
ordering = ["first_name", "last_name", "email"]
fieldsets = [
(
None,
{"fields": ["user", "first_name", "middle_name", "last_name", "title", "email", "phone"]},
)
]
autocomplete_fields = ["user"]
change_form_template = "django/admin/email_clipboard_change_form.html"
# We name the custom prop 'contact' because linter
# is not allowing a short_description attr on it
# This gets around the linter limitation, for now.
@ -847,6 +860,8 @@ class DomainInvitationAdmin(ListHeaderAdmin):
# error.
readonly_fields = ["status"]
change_form_template = "django/admin/email_clipboard_change_form.html"
class DomainInformationAdmin(ListHeaderAdmin):
"""Customize domain information admin class."""
@ -1404,6 +1419,8 @@ class TransitionDomainAdmin(ListHeaderAdmin):
search_fields = ["username", "domain_name"]
search_help_text = "Search by user or domain name."
change_form_template = "django/admin/email_clipboard_change_form.html"
class DomainInformationInline(admin.StackedInline):
"""Edit a domain information on the domain page.
@ -1870,6 +1887,13 @@ class DraftDomainAdmin(ListHeaderAdmin):
ordering = ["name"]
class PublicContactAdmin(ListHeaderAdmin):
"""Custom PublicContact admin class."""
change_form_template = "django/admin/email_clipboard_change_form.html"
autocomplete_fields = ["domain"]
class VerifiedByStaffAdmin(ListHeaderAdmin):
list_display = ("email", "requestor", "truncated_notes", "created_at")
search_fields = ["email"]
@ -1878,6 +1902,8 @@ class VerifiedByStaffAdmin(ListHeaderAdmin):
"requestor",
]
change_form_template = "django/admin/email_clipboard_change_form.html"
def truncated_notes(self, obj):
# Truncate the 'notes' field to 50 characters
return str(obj.notes)[:50]
@ -1915,7 +1941,7 @@ admin.site.register(models.FederalAgency, FederalAgencyAdmin)
# do not propagate to registry and logic not applied
admin.site.register(models.Host, MyHostAdmin)
admin.site.register(models.Website, WebsiteAdmin)
admin.site.register(models.PublicContact, AuditedAdmin)
admin.site.register(models.PublicContact, PublicContactAdmin)
admin.site.register(models.DomainRequest, DomainRequestAdmin)
admin.site.register(models.TransitionDomain, TransitionDomainAdmin)
admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin)

View file

@ -137,6 +137,94 @@ function openInNewTab(el, removeAttribute = false){
prepareDjangoAdmin();
})();
/** An IIFE for pages in DjangoAdmin that use a clipboard button
*/
(function (){
function copyInnerTextToClipboard(elem) {
let text = elem.innerText
navigator.clipboard.writeText(text)
}
function copyToClipboardAndChangeIcon(button) {
// Assuming the input is the previous sibling of the button
let input = button.previousElementSibling;
let userId = input.getAttribute("user-id")
// Copy input value to clipboard
if (input) {
navigator.clipboard.writeText(input.value).then(function() {
// Change the icon to a checkmark on successful copy
let buttonIcon = button.querySelector('.usa-button__clipboard use');
if (buttonIcon) {
let currentHref = buttonIcon.getAttribute('xlink:href');
let baseHref = currentHref.split('#')[0];
// Append the new icon reference
buttonIcon.setAttribute('xlink:href', baseHref + '#check');
// Change the button text
nearestSpan = button.querySelector("span")
nearestSpan.innerText = "Copied to clipboard"
setTimeout(function() {
// Change back to the copy icon
buttonIcon.setAttribute('xlink:href', currentHref);
if (button.classList.contains('usa-button__small-text')) {
nearestSpan.innerText = "Copy email";
} else {
nearestSpan.innerText = "Copy";
}
}, 2000);
}
}).catch(function(error) {
console.error('Clipboard copy failed', error);
});
}
}
function handleClipboardButtons() {
clipboardButtons = document.querySelectorAll(".usa-button__clipboard")
clipboardButtons.forEach((button) => {
// Handle copying the text to your clipboard,
// and changing the icon.
button.addEventListener("click", ()=>{
copyToClipboardAndChangeIcon(button);
});
// Add a class that adds the outline style on click
button.addEventListener("mousedown", function() {
this.classList.add("no-outline-on-click");
});
// But add it back in after the user clicked,
// for accessibility reasons (so we can still tab, etc)
button.addEventListener("blur", function() {
this.classList.remove("no-outline-on-click");
});
});
}
function handleClipboardLinks() {
let emailButtons = document.querySelectorAll(".usa-button__clipboard-link");
if (emailButtons){
emailButtons.forEach((button) => {
button.addEventListener("click", ()=>{
copyInnerTextToClipboard(button);
})
});
}
}
handleClipboardButtons();
handleClipboardLinks();
})();
/**
* An IIFE to listen to changes on filter_horizontal and enable or disable the change/delete/view buttons as applicable
*

View file

@ -392,32 +392,34 @@ address.margin-top-neg-1__detail-list {
margin-top: 5px !important;
}
// Mimic the normal label size
dt {
font-size: 0.8125rem;
color: var(--body-quiet-color);
}
address {
address, dt {
font-size: 0.8125rem;
color: var(--body-quiet-color);
}
}
td button.usa-button__clipboard-link, address.dja-address-contact-list {
font-size: unset;
}
address.dja-address-contact-list {
font-size: 0.8125rem;
color: var(--body-quiet-color);
button.usa-button__clipboard-link {
font-size: unset;
}
}
// Mimic the normal label size
@media (max-width: 1024px){
.dja-detail-list dt {
.dja-detail-list dt, .dja-detail-list address {
font-size: 0.875rem;
color: var(--body-quiet-color);
}
.dja-detail-list address {
font-size: 0.875rem;
color: var(--body-quiet-color);
address button.usa-button__clipboard-link, td button.usa-button__clipboard-link {
font-size: 0.875rem !important;
}
}
.errors span.select2-selection {
@ -533,3 +535,69 @@ address.dja-address-contact-list {
color: var(--link-fg);
}
}
// Make the clipboard button "float" inside of the input box
.admin-icon-group {
position: relative;
display: inline;
align-items: center;
input {
// Allow for padding around the copy button
padding-right: 35px !important;
// Match the height of other inputs
min-height: 2.25rem !important;
}
button {
line-height: 14px;
width: max-content;
font-size: unset;
text-decoration: none !important;
}
@media (max-width: 1000px) {
button {
display: block;
padding-top: 8px;
}
}
span {
padding-left: 0.1rem;
}
}
.admin-icon-group.admin-icon-group__clipboard-link {
position: relative;
display: inline;
align-items: center;
.usa-button__icon {
position: absolute;
right: auto;
left: 4px;
height: 100%;
}
button {
font-size: unset !important;
display: inline-flex;
padding-top: 4px;
line-height: 14px;
color: var(--link-fg);
width: max-content;
font-size: unset;
text-decoration: none !important;
}
}
.no-outline-on-click:focus {
outline: none !important;
}
.usa-button__small-text {
font-size: small;
}

View file

@ -2,7 +2,7 @@
// Only apply this custom wrapping to desktop
@include at-media(desktop) {
.usa-tooltip__body {
.usa-tooltip--registrar .usa-tooltip__body {
width: 350px;
white-space: normal;
text-align: center;
@ -10,7 +10,7 @@
}
@include at-media(tablet) {
.usa-tooltip__body {
.usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
@ -18,7 +18,7 @@
}
@include at-media(mobile) {
.usa-tooltip__body {
.usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;

View file

@ -0,0 +1,39 @@
{% load i18n static %}
{% comment %}
Template for an input field with a clipboard
{% endcomment %}
{% if not invisible_input_field %}
<div class="admin-icon-group">
{{ field }}
<button
class="usa-button usa-button--unstyled padding-left-1 usa-button__icon usa-button__clipboard"
type="button"
>
<div class="no-outline-on-click">
<svg
class="usa-icon"
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span>Copy</span>
</div>
</button>
</div>
{% else %}
<div class="admin-icon-group admin-icon-group__clipboard-link">
<input aria-hidden="true" class="display-none" value="{{ field.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button__icon usa-button__clipboard text-no-underline"
type="button"
>
<svg
class="usa-icon"
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span class="padding-left-05">Copy</span>
</button>
</div>
{% endif %}

View file

@ -0,0 +1,8 @@
{% extends 'admin/change_form.html' %}
{% load i18n static %}
{% block field_sets %}
{% for fieldset in adminform %}
{% include "django/admin/includes/email_clipboard_fieldset.html" %}
{% endfor %}
{% endblock %}

View file

@ -26,10 +26,12 @@
{% if user.email or user.contact.email %}
{% if user.contact.email %}
{{ user.contact.email }}
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% else %}
{{ user.email }}
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% endif %}
<br>
<br class="admin-icon-group__br">
{% else %}
None<br>
{% endif %}

View file

@ -92,8 +92,25 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<tr>
<th class="padding-left-1" scope="row">{{ contact.get_formatted_name }}</th>
<td class="padding-left-1">{{ contact.title }}</td>
<td class="padding-left-1">{{ contact.email }}</td>
<td class="padding-left-1">
{{ contact.email }}
</td>
<td class="padding-left-1">{{ contact.phone }}</td>
<td class="padding-left-1 text-size-small">
<input aria-hidden="true" class="display-none" value="{{ contact.email }}" />
<button
class="usa-button usa-button--unstyled padding-right-1 usa-button__icon usa-button__clipboard usa-button__small-text text-no-underline"
type="button"
>
<svg
class="usa-icon"
>
<use aria-hidden="true" xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg>
<span>Copy email</span>
</button>
</td>
</tr>
{% endfor %}
</tbody>

View file

@ -0,0 +1,13 @@
{% extends "django/admin/includes/detail_table_fieldset.html" %}
{% comment %}
This is using a custom implementation fieldset.html (see admin/fieldset.html)
{% endcomment %}
{% block field_other %}
{% if field.field.name == "email" %}
{% include "admin/input_with_clipboard.html" with field=field.field %}
{% else %}
{{ block.super }}
{% endif %}
{% endblock field_other %}

View file

@ -84,7 +84,7 @@
{% else %}
<input
type="submit"
class="usa-button--unstyled disabled-button usa-tooltip"
class="usa-button--unstyled disabled-button usa-tooltip usa-tooltip--registrar"
value="Remove"
data-position="bottom"
title="Domains must have at least one domain manager"

View file

@ -58,7 +58,7 @@
{{ domain.state|capfirst }}
{% endif %}
<svg
class="usa-icon usa-tooltip text-middle margin-bottom-05 text-accent-cool no-click-outline-and-cursor-help"
class="usa-icon usa-tooltip usa-tooltip--registrar text-middle margin-bottom-05 text-accent-cool no-click-outline-and-cursor-help"
data-position="top"
title="{{domain.get_state_help_text}}"
focusable="true"

View file

@ -158,7 +158,7 @@ class GenericTestHelper(TestCase):
Example Usage:
```
self.assert_sort_helper(
self.factory, self.superuser, self.admin, self.url, DomainInformation, "1", ("domain__name",)
"1", ("domain__name",)
)
```

View file

@ -1440,9 +1440,6 @@ class TestDomainRequestAdmin(MockEppLib):
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.
@ -1458,37 +1455,38 @@ class TestDomainRequestAdmin(MockEppLib):
self.assertContains(response, "Meoward Jones")
# == Check for the submitter == #
self.assertContains(response, "mayor@igorville.gov", count=2)
expected_submitter_fields = [
# Field, expected value
("title", "Admin Tester"),
("email", "mayor@igorville.gov"),
("phone", "(555) 555 5556"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == #
self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [
# Field, expected value
("title", "Chief Tester"),
("email", "testy@town.com"),
("phone", "(555) 555 5555"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields)
# count=5 because the underlying domain has two users with this name.
# The dropdown has 3 of these.
self.assertContains(response, "Testy Tester", count=5)
self.assertContains(response, "Testy Tester", count=10)
# == Test the other_employees field == #
self.assertContains(response, "testy2@town.com", count=2)
expected_other_employees_fields = [
# Field, expected value
("title", "Another Tester"),
("email", "testy2@town.com"),
("phone", "(555) 555 5557"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
self.assertContains(response, "usa-button__clipboard", count=4)
def test_save_model_sets_restricted_status_on_user(self):
with less_console_noise():
# make sure there is no user with this email
@ -2217,37 +2215,38 @@ class TestDomainInformationAdmin(TestCase):
self.assertContains(response, "Meoward Jones")
# == Check for the submitter == #
self.assertContains(response, "mayor@igorville.gov", count=2)
expected_submitter_fields = [
# Field, expected value
("title", "Admin Tester"),
("email", "mayor@igorville.gov"),
("phone", "(555) 555 5556"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_submitter_fields)
self.assertContains(response, "Testy2 Tester2")
# == Check for the authorizing_official == #
self.assertContains(response, "testy@town.com", count=2)
expected_ao_fields = [
# Field, expected value
("title", "Chief Tester"),
("email", "testy@town.com"),
("phone", "(555) 555 5555"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_ao_fields)
# count=5 because the underlying domain has two users with this name.
# The dropdown has 3 of these.
self.assertContains(response, "Testy Tester", count=5)
self.assertContains(response, "Testy Tester", count=10)
# == Test the other_employees field == #
self.assertContains(response, "testy2@town.com", count=2)
expected_other_employees_fields = [
# Field, expected value
("title", "Another Tester"),
("email", "testy2@town.com"),
("phone", "(555) 555 5557"),
]
self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields)
# Test for the copy link
self.assertContains(response, "usa-button__clipboard", count=4)
def test_readonly_fields_for_analyst(self):
"""Ensures that analysts have their permissions setup correctly"""
with less_console_noise():