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

View file

@ -137,6 +137,94 @@ function openInNewTab(el, removeAttribute = false){
prepareDjangoAdmin(); 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 * 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; margin-top: 5px !important;
} }
// Mimic the normal label size // Mimic the normal label size
dt { address, dt {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--body-quiet-color); color: var(--body-quiet-color);
} }
}
address { td button.usa-button__clipboard-link, address.dja-address-contact-list {
font-size: 0.8125rem; font-size: unset;
color: var(--body-quiet-color);
}
} }
address.dja-address-contact-list { address.dja-address-contact-list {
font-size: 0.8125rem;
color: var(--body-quiet-color); color: var(--body-quiet-color);
button.usa-button__clipboard-link {
font-size: unset;
}
} }
// Mimic the normal label size // Mimic the normal label size
@media (max-width: 1024px){ @media (max-width: 1024px){
.dja-detail-list dt { .dja-detail-list dt, .dja-detail-list address {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--body-quiet-color); color: var(--body-quiet-color);
} }
.dja-detail-list address {
font-size: 0.875rem; address button.usa-button__clipboard-link, td button.usa-button__clipboard-link {
color: var(--body-quiet-color); font-size: 0.875rem !important;
} }
} }
.errors span.select2-selection { .errors span.select2-selection {
@ -533,3 +535,69 @@ address.dja-address-contact-list {
color: var(--link-fg); 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 // Only apply this custom wrapping to desktop
@include at-media(desktop) { @include at-media(desktop) {
.usa-tooltip__body { .usa-tooltip--registrar .usa-tooltip__body {
width: 350px; width: 350px;
white-space: normal; white-space: normal;
text-align: center; text-align: center;
@ -10,7 +10,7 @@
} }
@include at-media(tablet) { @include at-media(tablet) {
.usa-tooltip__body { .usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important; width: 250px !important;
white-space: normal !important; white-space: normal !important;
text-align: center !important; text-align: center !important;
@ -18,7 +18,7 @@
} }
@include at-media(mobile) { @include at-media(mobile) {
.usa-tooltip__body { .usa-tooltip--registrar .usa-tooltip__body {
width: 250px !important; width: 250px !important;
white-space: normal !important; white-space: normal !important;
text-align: center !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.email or user.contact.email %}
{% if user.contact.email %} {% if user.contact.email %}
{{ user.contact.email }} {{ user.contact.email }}
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% else %} {% else %}
{{ user.email }} {{ user.email }}
{% include "admin/input_with_clipboard.html" with field=user invisible_input_field=True %}
{% endif %} {% endif %}
<br> <br class="admin-icon-group__br">
{% else %} {% else %}
None<br> None<br>
{% endif %} {% endif %}

View file

@ -92,8 +92,25 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html)
<tr> <tr>
<th class="padding-left-1" scope="row">{{ contact.get_formatted_name }}</th> <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.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">{{ 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> </tr>
{% endfor %} {% endfor %}
</tbody> </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 %} {% else %}
<input <input
type="submit" type="submit"
class="usa-button--unstyled disabled-button usa-tooltip" class="usa-button--unstyled disabled-button usa-tooltip usa-tooltip--registrar"
value="Remove" value="Remove"
data-position="bottom" data-position="bottom"
title="Domains must have at least one domain manager" title="Domains must have at least one domain manager"

View file

@ -58,7 +58,7 @@
{{ domain.state|capfirst }} {{ domain.state|capfirst }}
{% endif %} {% endif %}
<svg <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" data-position="top"
title="{{domain.get_state_help_text}}" title="{{domain.get_state_help_text}}"
focusable="true" focusable="true"

View file

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