mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-08-01 15:34:53 +02:00
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:
commit
3302dbab70
13 changed files with 295 additions and 35 deletions
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
39
src/registrar/templates/admin/input_with_clipboard.html
Normal file
39
src/registrar/templates/admin/input_with_clipboard.html
Normal 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 %}
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",)
|
||||
)
|
||||
```
|
||||
|
||||
|
|
|
@ -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():
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue