Sticky submit bar on domain request admin

This commit is contained in:
Rachid Mrad 2024-03-26 14:44:19 -04:00
parent aed723b6d2
commit f2c05c91ec
No known key found for this signature in database
12 changed files with 244 additions and 83 deletions

View file

@ -958,6 +958,7 @@ class DomainRequestAdmin(ListHeaderAdmin):
"""Custom domain requests admin class.""" """Custom domain requests admin class."""
form = DomainRequestAdminForm form = DomainRequestAdminForm
change_form_template = "django/admin/domain_request_change_form.html"
class InvestigatorFilter(admin.SimpleListFilter): class InvestigatorFilter(admin.SimpleListFilter):
"""Custom investigator filter that only displays users with the manager role""" """Custom investigator filter that only displays users with the manager role"""
@ -1019,8 +1020,6 @@ class DomainRequestAdmin(ListHeaderAdmin):
if self.value() == "0": if self.value() == "0":
return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None)) return queryset.filter(Q(is_election_board=False) | Q(is_election_board=None))
change_form_template = "django/admin/domain_request_change_form.html"
# Columns # Columns
list_display = [ list_display = [
"requested_domain", "requested_domain",

View file

@ -410,3 +410,60 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk,
}); });
observer.observe({ type: "navigation" }); observer.observe({ type: "navigation" });
})(); })();
/** An IIFE for toggling the submit bar on domain request forms
*/
(function (){
// Get a reference to the button element
const toggleButton = document.getElementById('submitRowToggle');
const submitRowWrapper = document.querySelector('.submit-row-wrapper');
if (toggleButton) {
// Add event listener to toggle the class and update content on click
toggleButton.addEventListener('click', function() {
// Toggle the 'collapsed' class on the bar
submitRowWrapper.classList.toggle('collapsed');
// Get a reference to the span element inside the button
const spanElement = this.querySelector('span');
// Get a reference to the use element inside the button
const useElement = this.querySelector('use');
// Check if the span element text is 'Hide'
if (spanElement.textContent.trim() === 'Hide') {
// Update the span element text to 'Show'
spanElement.textContent = 'Show';
// Update the xlink:href attribute to expand_more
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
} else {
// Update the span element text to 'Hide'
spanElement.textContent = 'Hide';
// Update the xlink:href attribute to expand_less
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
}
});
// We have a scroll indicator at the end of the page.
// Observe it. Once it gets on screen, test to see if the row is collapsed.
// If it is, expand it.
const targetElement = document.querySelector(".scroll-indicator");
const options = {
threshold: 1 // Adjust the threshold as needed (1 indicates when the target element is fully visible)
};
// Create a new Intersection Observer
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
// Refresh reference to submit row wrapper and check if it's collapsed
if (document.querySelector('.submit-row-wrapper').classList.contains('collapsed')) {
toggleButton.click();
}
}
});
}, options);
observer.observe(targetElement);
}
})();

View file

@ -54,64 +54,75 @@
})(); })();
document.addEventListener("DOMContentLoaded", function () { /** An IIFE to initialize the analytics page
createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date"); */
createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date"); (function () {
createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date"); function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) {
createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date"); var canvas = document.getElementById(canvasId);
createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date"); if (!canvas) {
createComparativeColumnChart("myChart6", "All requests", "Start Date", "End Date"); return
}); }
function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) { var ctx = canvas.getContext("2d");
var canvas = document.getElementById(canvasId);
var ctx = canvas.getContext("2d");
var listOne = JSON.parse(canvas.getAttribute('data-list-one')); var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
var listTwo = JSON.parse(canvas.getAttribute('data-list-two')); var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
var data = { var data = {
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"], labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
datasets: [ datasets: [
{ {
label: labelOne, label: labelOne,
backgroundColor: "rgba(255, 99, 132, 0.2)", backgroundColor: "rgba(255, 99, 132, 0.2)",
borderColor: "rgba(255, 99, 132, 1)", borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1, borderWidth: 1,
data: listOne, 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
}
}, },
{ scales: {
label: labelTwo, y: {
backgroundColor: "rgba(75, 192, 192, 0.2)", beginAtZero: true,
borderColor: "rgba(75, 192, 192, 1)", },
borderWidth: 1,
data: listTwo,
}, },
], };
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");
});
}; };
initComparativeColumnCharts();
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,
});
}

View file

@ -349,4 +349,36 @@ input.admin-confirm-button {
.errors span.select2-selection { .errors span.select2-selection {
border: 1px solid var(--error-fg) !important; border: 1px solid var(--error-fg) !important;
}
// Sticky submit bar for domain requests on desktop
@include at-media(desktop) {
.submit-row-wrapper {
position: fixed;
bottom: 0;
right: 0;
background: var(--darkened-bg);
border-top-left-radius: 6px;
transition: transform .2s ease-out;
.submit-row {
margin-bottom: 0;
}
}
.submit-row-wrapper.collapsed {
// translate3d is more performant than translateY
// https://stackoverflow.com/questions/22111256/translate3d-vs-translate-performance
transform: translate3d(0, 45px, 0);
}
.submit-row-toggle{
display: inline-block;
position: absolute;
top: -30px;
right: 0;
background: var(--darkened-bg);
}
#submitRowToggle {
color: var( --body-fg);
}
} }

View file

@ -117,6 +117,10 @@ abbr[title] {
} }
} }
.visible-desktop {
display: none;
}
@include at-media(desktop) { @include at-media(desktop) {
.float-right-desktop { .float-right-desktop {
float: right; float: right;
@ -124,33 +128,15 @@ abbr[title] {
.float-left-desktop { .float-left-desktop {
float: left; float: left;
} }
.visible-desktop {
display: block;
}
} }
.flex-end { .flex-end {
align-items: flex-end; align-items: flex-end;
} }
// Only apply this custom wrapping to desktop .cursor-pointer {
@include at-media(desktop) { cursor: pointer;
.usa-tooltip__body { }
width: 350px;
white-space: normal;
text-align: center;
}
}
@include at-media(tablet) {
.usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
}
}
@include at-media(mobile) {
.usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
}
}

View file

@ -138,6 +138,13 @@ a.usa-button--unstyled:visited {
} }
} }
.usa-button--unstyled--white,
.usa-button--unstyled--white:hover,
.usa-button--unstyled--white:focus,
.usa-button--unstyled--white:active {
color: color('white');
}
// Cancel button used on the // Cancel button used on the
// DNSSEC main page // DNSSEC main page
// We want to center this button on mobile // We want to center this button on mobile

View file

@ -0,0 +1,26 @@
@use "uswds-core" as *;
// Only apply this custom wrapping to desktop
@include at-media(desktop) {
.usa-tooltip__body {
width: 350px;
white-space: normal;
text-align: center;
}
}
@include at-media(tablet) {
.usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
}
}
@include at-media(mobile) {
.usa-tooltip__body {
width: 250px !important;
white-space: normal !important;
text-align: center !important;
}
}

View file

@ -13,6 +13,7 @@
@forward "lists"; @forward "lists";
@forward "buttons"; @forward "buttons";
@forward "forms"; @forward "forms";
@forward "tooltips";
@forward "fieldsets"; @forward "fieldsets";
@forward "alerts"; @forward "alerts";
@forward "tables"; @forward "tables";

View file

@ -92,5 +92,24 @@
</button> </button>
</div> </div>
</div> </div>
{{ block.super }}
<div class="submit-row-wrapper">
<span class="submit-row-toggle padding-1 padding-right-2">
<button type="button" class="usa-button usa-button--unstyled" id="submitRowToggle">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#expand_more"></use>
</svg>
<span>Hide</span>
</button>
</span>
<p class="visible-desktop text-right margin-top-2 padding-right-2 float-right">
<strong>Requested domain:</strong> {{ original.requested_domain.name }}
</p>
{{ block.super }}
</div>
<span class="scroll-indicator"></span>
{% endblock %} {% endblock %}

View file

@ -134,7 +134,7 @@
<td data-label="Status">{{ invitation.status|title }}</td> <td data-label="Status">{{ invitation.status|title }}</td>
<td> <td>
<form method="POST" action="{% url "invitation-delete" pk=invitation.id %}"> <form method="POST" action="{% url "invitation-delete" pk=invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline" value="Cancel"> {% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
</form> </form>
</td> </td>
</tr> </tr>

View file

@ -2,6 +2,7 @@ from datetime import date
from django.test import TestCase, RequestFactory, Client, override_settings from django.test import TestCase, RequestFactory, Client, override_settings
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
from contextlib import ExitStack from contextlib import ExitStack
from api.tests.common import less_console_noise_decorator
from django_webtest import WebTest # type: ignore from django_webtest import WebTest # type: ignore
from django.contrib import messages from django.contrib import messages
from django.urls import reverse from django.urls import reverse
@ -1211,6 +1212,28 @@ class TestDomainRequestAdmin(MockEppLib):
# Test that approved domain exists and equals requested domain # Test that approved domain exists and equals requested domain
self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name) self.assertEqual(domain_request.requested_domain.name, domain_request.approved_domain.name)
@less_console_noise_decorator
def test_sticky_submit_row_shows_requested_domain(self):
"""Test that the change_form template contains a string indicative of the customization
of the sticky submit bar."""
# make sure there is no user with this email
EMAIL = "mayor@igorville.gov"
User.objects.filter(email=EMAIL).delete()
self.client.force_login(self.superuser)
# Create a sample domain request
domain_request = completed_domain_request(status=DomainRequest.DomainRequestStatus.IN_REVIEW)
# Create a mock request
request = self.client.post("/admin/registrar/domainrequest/{}/change/".format(domain_request.pk))
# Since we're using client to mock the request, we can only test against
# non-interpolated values
expected_content = "<strong>Requested domain:</strong>"
self.assertContains(request, expected_content)
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