This commit is contained in:
Rachid Mrad 2024-09-05 00:13:18 -04:00
parent cc7f588dad
commit be8a618791
No known key found for this signature in database
12 changed files with 212 additions and 22 deletions

View file

@ -1168,7 +1168,6 @@ document.addEventListener('DOMContentLoaded', function() {
const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]'); const statusCheckboxes = document.querySelectorAll('input[name="filter-status"]');
const statusIndicator = document.querySelector('.domain__filter-indicator'); const statusIndicator = document.querySelector('.domain__filter-indicator');
const statusToggle = document.querySelector('.usa-button--filter'); const statusToggle = document.querySelector('.usa-button--filter');
const noPortfolioFlag = document.getElementById('no-portfolio-js-flag');
const portfolioElement = document.getElementById('portfolio-js-value'); const portfolioElement = document.getElementById('portfolio-js-value');
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null; const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
@ -1226,7 +1225,7 @@ document.addEventListener('DOMContentLoaded', function() {
let markupForSuborganizationRow = ''; let markupForSuborganizationRow = '';
if (!noPortfolioFlag) { if (portfolioValue) {
markupForSuborganizationRow = ` markupForSuborganizationRow = `
<td> <td>
<span class="${suborganization ? 'ellipsis ellipsis--30 vertical-align-middle' : ''}" aria-label="${suborganization}" title="${suborganization}">${suborganization}</span> <span class="${suborganization ? 'ellipsis ellipsis--30 vertical-align-middle' : ''}" aria-label="${suborganization}" title="${suborganization}">${suborganization}</span>
@ -1485,6 +1484,8 @@ document.addEventListener('DOMContentLoaded', function() {
const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]'); const tableHeaders = document.querySelectorAll('.domain-requests__table th[data-sortable]');
const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region'); const tableAnnouncementRegion = document.querySelector('.domain-requests__table-wrapper .usa-table__announcement-region');
const resetSearchButton = document.querySelector('.domain-requests__reset-search'); const resetSearchButton = document.querySelector('.domain-requests__reset-search');
const portfolioElement = document.getElementById('portfolio-js-value');
const portfolioValue = portfolioElement ? portfolioElement.getAttribute('data-portfolio') : null;
/** /**
* Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input. * Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input.
@ -1533,7 +1534,7 @@ document.addEventListener('DOMContentLoaded', function() {
* @param {*} scroll - control for the scrollToElement functionality * @param {*} scroll - control for the scrollToElement functionality
* @param {*} searchTerm - the search term * @param {*} searchTerm - the search term
*/ */
function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm) { function loadDomainRequests(page, sortBy = currentSortBy, order = currentOrder, scroll = scrollToTable, searchTerm = currentSearchTerm, portfolio = portfolioValue) {
// fetch json of page of domain requests, given params // fetch json of page of domain requests, given params
let baseUrl = document.getElementById("get_domain_requests_json_url"); let baseUrl = document.getElementById("get_domain_requests_json_url");
if (!baseUrl) { if (!baseUrl) {
@ -1545,7 +1546,12 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
fetch(`${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`) // fetch json of page of requests, given params
let url = `${baseUrlValue}?page=${page}&sort_by=${sortBy}&order=${order}&search_term=${searchTerm}`
if (portfolio)
url += `&portfolio=${portfolio}`
fetch(url)
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
if (data.error) { if (data.error) {
@ -1601,10 +1607,11 @@ document.addEventListener('DOMContentLoaded', function() {
const actionLabel = request.action_label; const actionLabel = request.action_label;
const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`; const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `<span class="text-base">Not submitted</span>`;
// Even if the request is not deletable, we may need this empty string for the td if the deletable column is displayed // Delete markup will either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page)
// Even if the request is not deletable, we may need these empty strings for the td if the deletable column is displayed
let modalTrigger = ''; let modalTrigger = '';
// If the request is deletable, create modal body and insert it // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages
if (request.is_deletable) { if (request.is_deletable) {
let modalHeading = ''; let modalHeading = '';
let modalDescription = ''; let modalDescription = '';
@ -1692,7 +1699,44 @@ document.addEventListener('DOMContentLoaded', function() {
` `
domainRequestsSectionWrapper.appendChild(modal); domainRequestsSectionWrapper.appendChild(modal);
// Request is deletable, modal and modalTrigger are built. Now test is portfolio requests page and enhace the modalTrigger markup
if (portfolioValue) {
modalTrigger = `
<div class="usa-accordion usa-accordion--more-actions margin-right-2">
<div class="usa-accordion__heading">
<button
type="button"
class="usa-button usa-button--unstyled usa-accordion__button usa-button--more-actions"
aria-expanded="false"
aria-controls="more-actions-${request.id}"
>
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#more_vert"></use>
</svg>
</button>
</div>
<div id="more-actions-${request.id}" class="usa-accordion__content usa-prose shadow-1 left-auto right-0" hidden>
<h2>More options</h2>
<a
role="button"
id="button-toggle-delete-domain-alert-${request.id}"
href="#toggle-delete-domain-alert-${request.id}"
class="usa-button--unstyled text-no-underline late-loading-modal-trigger"
aria-controls="toggle-delete-domain-alert-${request.id}"
data-open-modal
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#delete"></use>
</svg> Delete <span class="usa-sr-only">${domainName}</span>
</a>
</div>
</div>
`
} }
}
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = ` row.innerHTML = `
@ -1817,6 +1861,36 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
function closeMoreActionMenu(accordionIsOpen) {
if (accordionIsOpen.getAttribute("aria-expanded") === "true") {
accordionIsOpen.click();
}
}
document.addEventListener('focusin', function(event) {
const accordions = document.querySelectorAll('.usa-accordion--more-actions');
const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]');
openAccordions.forEach((openAccordionButton) => {
const accordion = openAccordionButton.closest('.usa-accordion--more-actions'); // Find the corresponding accordion
if (accordion && !accordion.contains(event.target)) {
closeMoreActionMenu(openAccordionButton); // Close the accordion if the focus is outside
}
});
});
document.addEventListener('click', function(event) {
const accordions = document.querySelectorAll('.usa-accordion--more-actions');
const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]');
openAccordions.forEach((openAccordionButton) => {
const accordion = openAccordionButton.closest('.usa-accordion--more-actions'); // Find the corresponding accordion
if (accordion && !accordion.contains(event.target)) {
closeMoreActionMenu(openAccordionButton); // Close the accordion if the click is outside
}
});
});
// Initial load // Initial load
loadDomainRequests(1); loadDomainRequests(1);
} }

View file

@ -1,6 +1,7 @@
@use "uswds-core" as *; @use "uswds-core" as *;
.usa-accordion--select { .usa-accordion--select,
.usa-accordion--more-actions {
display: inline-block; display: inline-block;
width: auto; width: auto;
position: relative; position: relative;

View file

@ -192,3 +192,8 @@ abbr[title] {
max-width: 50ch; max-width: 50ch;
} }
} }
// Boost this USWDS utility class for the accordions in the portfolio requests table
.left-auto {
left: auto!important;
}

View file

@ -89,14 +89,16 @@
.usa-nav__primary { .usa-nav__primary {
.usa-nav-link, .usa-nav-link,
.usa-nav-link:hover, .usa-nav-link:hover,
.usa-nav-link:active { .usa-nav-link:active,
button {
color: color('primary'); color: color('primary');
font-weight: font-weight('normal'); font-weight: font-weight('normal');
font-size: 16px; font-size: 16px;
} }
.usa-current, .usa-current,
.usa-current:hover, .usa-current:hover,
.usa-current:active { .usa-current:active,
button.usa-current {
font-weight: font-weight('bold'); font-weight: font-weight('bold');
} }
} }

View file

@ -78,6 +78,11 @@ urlpatterns = [
views.PortfolioDomainRequestsView.as_view(), views.PortfolioDomainRequestsView.as_view(),
name="domain-requests", name="domain-requests",
), ),
path(
"no-organization-requests/",
views.PortfolioNoDomainRequestsView.as_view(),
name="no-portfolio-requests",
),
path( path(
"organization/", "organization/",
views.PortfolioOrganizationView.as_view(), views.PortfolioOrganizationView.as_view(),

View file

@ -5,10 +5,13 @@
<span id="get_domain_requests_json_url" class="display-none">{{url}}</span> <span id="get_domain_requests_json_url" class="display-none">{{url}}</span>
<section class="section--outlined domain-requests" id="domain-requests"> <section class="section--outlined domain-requests" id="domain-requests">
<div class="grid-row"> <div class="grid-row">
{% if not has_domain_requests_portfolio_permission %} {% if not portfolio %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<h2 id="domain-requests-header" class="flex-6">Domain requests</h2> <h2 id="domain-requests-header" class="flex-6">Domain requests</h2>
</div> </div>
{% else %}
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %} {% endif %}
<div class="mobile:grid-col-12 desktop:grid-col-6"> <div class="mobile:grid-col-12 desktop:grid-col-6">
<section aria-label="Domain requests search component" class="flex-6 margin-y-2"> <section aria-label="Domain requests search component" class="flex-6 margin-y-2">
@ -45,7 +48,7 @@
<thead> <thead>
<tr> <tr>
<th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th> <th data-sortable="requested_domain__name" scope="col" role="columnheader">Domain name</th>
<th data-sortable="last_submitted_date" scope="col" role="columnheader">Date submitted</th> <th data-sortable="last_submitted_date" scope="col" role="columnheader">Submitted</th>
<th data-sortable="status" scope="col" role="columnheader">Status</th> <th data-sortable="status" scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th> <th scope="col" role="columnheader"><span class="usa-sr-only">Action</span></th>
<!-- AJAX will conditionally add a th for delete actions --> <!-- AJAX will conditionally add a th for delete actions -->

View file

@ -9,7 +9,6 @@
<div class="section--outlined__header margin-bottom-3 {% if not portfolio %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}"> <div class="section--outlined__header margin-bottom-3 {% if not portfolio %} section--outlined__header--no-portfolio justify-content-space-between{% else %} grid-row{% endif %}">
{% if not portfolio %} {% if not portfolio %}
<h2 id="domains-header" class="display-inline-block">Domains</h2> <h2 id="domains-header" class="display-inline-block">Domains</h2>
<span class="display-none" id="no-portfolio-js-flag"></span>
{% else %} {% else %}
<!-- Embedding the portfolio value in a data attribute --> <!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span> <span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>

View file

@ -29,14 +29,38 @@
</a> </a>
</li> </li>
{% if has_domain_requests_portfolio_permission %}
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
{% if has_domain_requests_portfolio_permission %}
{% url 'domain-requests' as url %} {% url 'domain-requests' as url %}
<button
type="button"
class="usa-accordion__button usa-nav__link{% if 'request'|in_path:request.path %} usa-current{% endif %}"
aria-expanded="false"
aria-controls="basic-nav-section-two"
>
<span>Domain requests</span>
</button>
<ul id="basic-nav-section-two" class="usa-nav__submenu">
<li class="usa-nav__submenu-item">
<a href="{{ url }}"
><span>Domain requests</span></a
>
</li>
<li class="usa-nav__submenu-item">
<a href="{% url 'domain-request:' %}"
><span>Start a new domain request</span></a
>
</li>
</ul>
{% else %}
{% url 'no-portfolio-requests' as url %}
<a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}"> <a href="{{ url }}" class="usa-nav-link{% if 'request'|in_path:request.path %} usa-current{% endif %}">
Domain requests Domain requests
</a> </a>
</li>
{% endif %} {% endif %}
</li>
<li class="usa-nav__primary-item"> <li class="usa-nav__primary-item">
<a href="#" class="usa-nav-link"> <a href="#" class="usa-nav-link">
Members Members

View file

@ -0,0 +1,30 @@
{% extends 'portfolio_base.html' %}
{% load static %}
{% block title %} Domain Requests | {% endblock %}
{% block portfolio_content %}
<h1 id="domains-header">Current domain requests</h1>
<section class="section--outlined">
<div class="section--outlined__header margin-bottom-3">
<h2 id="domains-header" class="display-inline-block">You dont have access to domain requests.</h2>
{% if portfolio_administrators %}
<p>If you believe you should have access to a request, reach out to your organizations administrators.</p>
<p>Your organizations administrators:</p>
<ul class="margin-top-0">
{% for administrator in portfolio_administrators %}
{% if administrator.email %}
<li>{{ administrator.email }}</li>
{% else %}
<li>{{ administrator }}</li>
{% endif %}
{% endfor %}
</ul>
{% else %}
<p><strong>No administrators were found on your organization.</strong></p>
<p>If you believe you should have access to a request, email <a href="mailto:help@get.gov" class="usa-link">help@get.gov</a>.</p>
{% endif %}
</div>
</section>
{% endblock %}

View file

@ -12,9 +12,9 @@ def get_domain_requests_json(request):
"""Given the current request, """Given the current request,
get all domain requests that are associated with the request user and exclude the APPROVED ones""" get all domain requests that are associated with the request user and exclude the APPROVED ones"""
domain_requests = DomainRequest.objects.filter(creator=request.user).exclude( domain_request_ids = get_domain_requests_ids_from_request(request)
status=DomainRequest.DomainRequestStatus.APPROVED
) domain_requests = DomainRequest.objects.filter(id__in=domain_request_ids)
unfiltered_total = domain_requests.count() unfiltered_total = domain_requests.count()
# Handle sorting # Handle sorting
@ -97,3 +97,21 @@ def get_domain_requests_json(request):
"unfiltered_total": unfiltered_total, "unfiltered_total": unfiltered_total,
} }
) )
def get_domain_requests_ids_from_request(request):
"""Get domain request ids from request.
If portfolio specified, return domain request ids associated with portfolio.
Otherwise, return domain request ids associated with request.user.
"""
portfolio = request.GET.get("portfolio")
if portfolio:
domain_requests = DomainRequest.objects.filter(portfolio=portfolio).exclude(
status=DomainRequest.DomainRequestStatus.APPROVED
)
else:
domain_requests = DomainRequest.objects.filter(creator=request.user).exclude(
status=DomainRequest.DomainRequestStatus.APPROVED
)
return domain_requests.values_list("id", flat=True)

View file

@ -47,7 +47,36 @@ class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
""" """
model = Portfolio model = Portfolio
template_name = "no_portfolio_domains.html" template_name = "portfolio_no_domains.html"
def get(self, request):
return render(request, self.template_name, context=self.get_context_data())
def get_context_data(self, **kwargs):
"""Add additional context data to the template."""
# We can override the base class. This view only needs this item.
context = {}
portfolio = self.request.session.get("portfolio")
if portfolio:
admin_ids = UserPortfolioPermission.objects.filter(
portfolio=portfolio,
roles__overlap=[
UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
],
).values_list("user__id", flat=True)
admin_users = User.objects.filter(id__in=admin_ids)
context["portfolio_administrators"] = admin_users
return context
class PortfolioNoDomainRequestsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domain requests.
This is a custom view which explains that to the user - and denotes who to contact.
"""
model = Portfolio
template_name = "portfolio_no_domain_requests.html"
def get(self, request): def get(self, request):
return render(request, self.template_name, context=self.get_context_data()) return render(request, self.template_name, context=self.get_context_data())