Initial Scaffolding (in progress)

This commit is contained in:
CocoByte 2024-09-20 12:28:42 -06:00
parent 3495101e9b
commit 6954c1c2c1
No known key found for this signature in database
GPG key ID: BBFAA2526384C97F
5 changed files with 601 additions and 0 deletions

View file

@ -1853,6 +1853,144 @@ class DomainRequestsTable extends LoadTableBase {
}
}
class MembersTable extends LoadTableBase {
constructor() {
super('.members__table', '.members__table-wrapper', '#members__search-field', '#members__search-field-submit', '.members__reset-search', '.members__reset-filters', '.members__no-data', '.members__no-search-results');
}
/**
* Loads rows in the members list, as well as updates pagination around the members list
* based on the supplied attributes.
* @param {*} page - the page number of the results (starts with 1)
* @param {*} sortBy - the sort column option
* @param {*} order - the sort order {asc, desc}
* @param {*} scroll - control for the scrollToElement functionality
* @param {*} status - control for the status filter
* @param {*} searchTerm - the search term
* @param {*} portfolio - the portfolio id
*/
loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
// fetch json of page of domais, given params
let baseUrl = document.getElementById("get_members_json_url");
if (!baseUrl) {
return;
}
let baseUrlValue = baseUrl.innerHTML;
if (!baseUrlValue) {
return;
}
// fetch json of page of members, given params
let searchParams = new URLSearchParams(
{
"page": page,
"sort_by": sortBy,
"order": order,
"status": status,
"search_term": searchTerm
}
);
if (portfolio)
searchParams.append("portfolio", portfolio)
let url = `${baseUrlValue}?${searchParams.toString()}`
fetch(url)
.then(response => response.json())
.then(data => {
if (data.error) {
console.error('Error in AJAX call: ' + data.error);
return;
}
// handle the display of proper messaging in the event that no members exist in the list or search returns no results
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
// identify the DOM element where the domain list will be inserted into the DOM
const domainList = document.querySelector('.members__table tbody');
domainList.innerHTML = '';
data.members.forEach(domain => {
const options = { year: 'numeric', month: 'short', day: 'numeric' };
const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null;
const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : '';
const expirationDateSortValue = expirationDate ? expirationDate.getTime() : '';
const actionUrl = domain.action_url;
const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯';
const row = document.createElement('tr');
let markupForSuborganizationRow = '';
if (this.portfolioValue) {
markupForSuborganizationRow = `
<td>
<span class="text-wrap" aria-label="${domain.suborganization ? suborganization : 'No suborganization'}">${suborganization}</span>
</td>
`
}
row.innerHTML = `
<th scope="row" role="rowheader" data-label="Domain name">
${domain.name}
</th>
<td data-sort-value="${expirationDateSortValue}" data-label="Expires">
${expirationDateFormatted}
</td>
<td data-label="Status">
${domain.state_display}
<svg
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"
aria-label="${domain.get_state_help_text}"
role="tooltip"
>
<use aria-hidden="true" xlink:href="/public/img/sprite.svg#info_outline"></use>
</svg>
</td>
${markupForSuborganizationRow}
<td>
<a href="${actionUrl}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${domain.svg_icon}"></use>
</svg>
${domain.action_label} <span class="usa-sr-only">${domain.name}</span>
</a>
</td>
`;
domainList.appendChild(row);
});
// initialize tool tips immediately after the associated DOM elements are added
initializeTooltips();
// Do not scroll on first page load
if (scroll)
ScrollToElement('class', 'members');
this.scrollToTable = true;
// update pagination
this.updatePagination(
'domain',
'#members-pagination',
'#members-pagination .usa-pagination__counter',
'#members',
data.page,
data.num_pages,
data.has_previous,
data.has_next,
data.total,
);
this.currentSortBy = sortBy;
this.currentOrder = order;
this.currentSearchTerm = searchTerm;
})
.catch(error => console.error('Error fetching members:', error));
}
}
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
@ -1926,6 +2064,23 @@ const utcDateString = (dateString) => {
};
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
* initializes the domains list and associated functionality on the home page of the app.
*
*/
document.addEventListener('DOMContentLoaded', function() {
const isMembersPage = document.querySelector("#members")
if (isMembersPage){
const membersTable = new MembersTable();
if (membersTable.tableWrapper) {
// Initial load
membersTable.loadTable(1);
}
}
});
/**
* An IIFE that displays confirmation modal on the user profile page
*/

View file

@ -0,0 +1,216 @@
{% load static %}
{% comment %} Stores the json endpoint in a url for easier access {% endcomment %}
{% url 'get_portfolio_members_json' as url %}
<span id="get_members_json_url" class="display-none">{{url}}</span>
<section class="section-outlined members margin-top-0{% if portfolio %} section-outlined--border-base-light{% endif %}" id="members">
<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 %}
<h2 id="members-header" class="display-inline-block">Members</h2>
{% else %}
<!-- Embedding the portfolio value in a data attribute -->
<span id="portfolio-js-value" data-portfolio="{{ portfolio.id }}"></span>
{% endif %}
<!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6{% endif %}">
<section aria-label="Members search component" class="margin-top-2">
<form class="usa-search usa-search--small" method="POST" role="search">
{% csrf_token %}
<button class="usa-button usa-button--unstyled margin-right-3 members__reset-search display-none" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
<label class="usa-sr-only" for="members__search-field">Search by domain name</label>
<input
class="usa-input"
id="members__search-field"
type="search"
name="search"
placeholder="Search by domain name"
/>
<button class="usa-button" type="submit" id="members__search-field-submit">
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</form>
</section>
</div>
<!-- ---------- Export as CSV ---------- -->
{% if portfolio_members_count and portfolio_members_count > 0 %}
<!--
=====================
TODO: future ticket?
=====================
-->
<!-- <div class="section-outlined__utility-button mobile-lg:padding-right-105 {% if portfolio %} mobile:grid-col-12 desktop:grid-col-6 desktop:padding-left-3{% endif %}">
<section aria-label="Members report component" class="margin-top-205">
<a href="{% url 'export_data_type_user' %}" class="usa-button usa-button--unstyled usa-button--with-icon" role="button">
<svg class="usa-icon usa-icon--big" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#file_download"></use>
</svg>Export as CSV
</a>
</section>
</div> -->
{% endif %}
</div>
{% if portfolio %}
<!-- ---------- FILTERING ---------- -->
<!--
=====================
TODO: future ticket?
=====================
-->
<!-- <div class="display-flex flex-align-center">
<span class="margin-right-2 margin-top-neg-1 usa-prose text-base-darker">Filter by</span>
<div class="usa-accordion usa-accordion--select margin-right-2">
<div class="usa-accordion__heading">
<button
type="button"
class="usa-button usa-button--small padding--8-8-9 usa-button--outline usa-button--filter usa-accordion__button"
aria-expanded="false"
aria-controls="filter-status"
>
<span class="filter-indicator text-bold display-none"></span> Status
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#expand_more"></use>
</svg>
</button>
</div>
<div id="filter-status" class="usa-accordion__content usa-prose shadow-1">
<h2>Status</h2>
<fieldset class="usa-fieldset margin-top-0">
<legend class="usa-legend">Select to apply <span class="sr-only">status</span> filter</legend>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-dns-needed"
type="checkbox"
name="filter-status"
value="unknown"
/>
<label class="usa-checkbox__label" for="filter-status-dns-needed"
>DNS Needed</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-ready"
type="checkbox"
name="filter-status"
value="ready"
/>
<label class="usa-checkbox__label" for="filter-status-ready"
>Ready</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-on-hold"
type="checkbox"
name="filter-status"
value="on hold"
/>
<label class="usa-checkbox__label" for="filter-status-on-hold"
>On hold</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-expired"
type="checkbox"
name="filter-status"
value="expired"
/>
<label class="usa-checkbox__label" for="filter-status-expired"
>Expired</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-deleted"
type="checkbox"
name="filter-status"
value="deleted"
/>
<label class="usa-checkbox__label" for="filter-status-deleted"
>Deleted</label
>
</div>
</fieldset>
</div>
</div>
<button
type="button"
class="usa-button usa-button--small padding--8-12-9-12 usa-button--outline usa-button--filter members__reset-filters display-none"
>
Clear filters
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#close"></use>
</svg>
</button>
</div> -->
{% endif %}
<!-- ---------- MAIN TABLE ---------- -->
<div class="members__table-wrapper display-none usa-table-container--scrollable margin-top-0" tabindex="0">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked members__table">
<caption class="sr-only">Your registered members</caption>
<thead>
<tr>
<th data-sortable="member" scope="col" role="columnheader">Member</th>
<th data-sortable="last_active" scope="col" role="columnheader">Last Active</th>
<th
scope="col"
role="columnheader"
>
<span class="usa-sr-only">Action</span>
</th>
</tr>
</thead>
<tbody>
<!-- AJAX will populate this tbody -->
</tbody>
</table>
<div
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
></div>
</div>
<div class="members__no-data display-none">
<p>You don't have any members.</p>
<!--
=====================
TODO: discard me?
=====================
-->
<!-- <p class="maxw-none clearfix">
<a href="https://get.gov/help/faq/#do-not-see-my-domain" class="float-right-tablet usa-link usa-link--icon" target="_blank">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#help_outline"></use>
</svg>
Why don't I see my domain when I sign in to the registrar?
</a>
</p> -->
</div>
<div class="members__no-search-results display-none">
<p>No results found</p>
</div>
</section>
<nav aria-label="Pagination" class="usa-pagination flex-justify" id="members-pagination">
<span class="usa-pagination__counter text-base-dark padding-left-2 margin-bottom-1">
<!-- Count will be dynamically populated by JS -->
</span>
<ul class="usa-pagination__list">
<!-- Pagination links will be dynamically populated by JS -->
</ul>
</nav>

View file

@ -0,0 +1,20 @@
{% extends 'portfolio_base.html' %}
{% load static %}
{% block title %} Members | {% endblock %}
{% block wrapper_class %}
{{ block.super }} dashboard--grey-1
{% endblock %}
{% block portfolio_content %}
{% block messages %}
{% include "includes/form_messages.html" %}
{% endblock %}
<div id="main-content">
<h1 id="members-header">Members</h1>
{% include "includes/members_table.html" with portfolio=portfolio portfolio_members_count=portfolio_members_count %}
</div>
{% endblock %}

View file

@ -0,0 +1,171 @@
from django.http import JsonResponse
from django.core.paginator import Paginator
from registrar.models import DomainRequest
from django.utils.dateformat import format
from django.contrib.auth.decorators import login_required
from django.urls import reverse
from django.db.models import Q
from registrar.models.user_portfolio_permission import UserPortfolioPermission
@login_required
def get_portfolio_members_json(request):
"""Given the current request,
get all members that are associated with the given portfolio"""
member_ids = get_member_ids_from_request(request)
unfiltered_total = member_ids.count()
# objects = apply_search(objects, request)
# objects = apply_status_filter(objects, request)
# objects = apply_sorting(objects, request)
paginator = Paginator(member_ids, 10)
page_number = request.GET.get("page", 1)
page_obj = paginator.get_page(page_number)
members = [
serialize_members(request, member, request.user) for member in page_obj.object_list
]
# return JsonResponse(
# {
# "domain_requests": domain_requests,
# "has_next": page_obj.has_next(),
# "has_previous": page_obj.has_previous(),
# "page": page_obj.number,
# "num_pages": paginator.num_pages,
# "total": paginator.count,
# "unfiltered_total": unfiltered_total,
# }
# )
def get_member_ids_from_request(request):
"""Given the current request,
get all members that are associated with the given portfolio"""
portfolio = request.GET.get("portfolio")
# filter_condition = Q(creator=request.user)
if portfolio:
# TODO: Permissions??
# if request.user.is_org_user(request) and request.user.has_view_all_requests_portfolio_permission(portfolio):
# filter_condition = Q(portfolio=portfolio)
# else:
# filter_condition = Q(portfolio=portfolio, creator=request.user)
member_ids = UserPortfolioPermission.objects.filter(
portfolio=portfolio
).values_list("user__id", flat=True)
return member_ids
# def apply_search(queryset, request):
# search_term = request.GET.get("search_term")
# is_portfolio = request.GET.get("portfolio")
# if search_term:
# search_term_lower = search_term.lower()
# new_domain_request_text = "new domain request"
# # Check if the search term is a substring of 'New domain request'
# # If yes, we should return domain requests that do not have a
# # requested_domain (those display as New domain request in the UI)
# if search_term_lower in new_domain_request_text:
# queryset = queryset.filter(
# Q(requested_domain__name__icontains=search_term) | Q(requested_domain__isnull=True)
# )
# elif is_portfolio:
# queryset = queryset.filter(
# Q(requested_domain__name__icontains=search_term)
# | Q(creator__first_name__icontains=search_term)
# | Q(creator__last_name__icontains=search_term)
# | Q(creator__email__icontains=search_term)
# )
# # For non org users
# else:
# queryset = queryset.filter(Q(requested_domain__name__icontains=search_term))
# return queryset
# def apply_status_filter(queryset, request):
# status_param = request.GET.get("status")
# if status_param:
# status_list = status_param.split(",")
# statuses = [status for status in status_list if status in DomainRequest.DomainRequestStatus.values]
# # Construct Q objects for statuses that can be queried through ORM
# status_query = Q()
# if statuses:
# status_query |= Q(status__in=statuses)
# # Apply the combined query
# queryset = queryset.filter(status_query)
# return queryset
# def apply_sorting(queryset, request):
# sort_by = request.GET.get("sort_by", "id") # Default to 'id'
# order = request.GET.get("order", "asc") # Default to 'asc'
# if order == "desc":
# sort_by = f"-{sort_by}"
# return queryset.order_by(sort_by)
def serialize_members(request, member, user):
# ------- DELETABLE
# deletable_statuses = [
# DomainRequest.DomainRequestStatus.STARTED,
# DomainRequest.DomainRequestStatus.WITHDRAWN,
# ]
# # Determine if the request is deletable
# if not user.is_org_user(request):
# is_deletable = member.status in deletable_statuses
# else:
# portfolio = request.session.get("portfolio")
# is_deletable = (
# member.status in deletable_statuses and user.has_edit_request_portfolio_permission(portfolio)
# ) and member.creator == user
# ------- EDIT / VIEW
# # Determine action label based on user permissions and request status
# editable_statuses = [
# DomainRequest.DomainRequestStatus.STARTED,
# DomainRequest.DomainRequestStatus.ACTION_NEEDED,
# DomainRequest.DomainRequestStatus.WITHDRAWN,
# ]
# if user.has_edit_request_portfolio_permission and member.creator == user:
# action_label = "Edit" if member.status in editable_statuses else "Manage"
# else:
# action_label = "View"
# # Map the action label to corresponding URLs and icons
# action_url_map = {
# "Edit": reverse("edit-domain-request", kwargs={"id": member.id}),
# "Manage": reverse("domain-request-status", kwargs={"pk": member.id}),
# "View": "#",
# }
# svg_icon_map = {"Edit": "edit", "Manage": "settings", "View": "visibility"}
# ------- INVITED
# TODO:....
# ------- SERIALIZE
# return {
# "requested_domain": member.requested_domain.name if member.requested_domain else None,
# "last_submitted_date": member.last_submitted_date,
# "status": member.get_status_display(),
# "created_at": format(member.created_at, "c"), # Serialize to ISO 8601
# "creator": member.creator.email,
# "id": member.id,
# "is_deletable": is_deletable,
# "action_url": action_url_map.get(action_label),
# "action_label": action_label,
# "svg_icon": svg_icon_map.get(action_label),
# }

View file

@ -41,6 +41,45 @@ class PortfolioDomainRequestsView(PortfolioDomainRequestsPermissionView, View):
return render(request, "portfolio_requests.html")
class PortfolioMembersView(PortfolioMembersPermissionView, View):
template_name = "portfolio_members.html"
def get(self, request):
"""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:
# # ------ Gets admin members
# admin_ids = UserPortfolioPermission.objects.filter(
# portfolio=portfolio,
# roles__overlap=[
# UserPortfolioRoleChoices.ORGANIZATION_ADMIN,
# ],
# ).values_list("user__id", flat=True)
# # ------ Gets non-admin members
# # Filter UserPortfolioPermission objects related to the portfolio that do NOT have the "Admin" role
# non_admin_permissions = UserPortfolioPermission.objects.filter(portfolio=obj).exclude(
# roles__contains=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN]
# )
# # Get the user objects associated with these permissions
# non_admin_users = User.objects.filter(portfolio_permissions__in=non_admin_permissions)
# ------- Gets all members
member_ids = UserPortfolioPermission.objects.filter(
portfolio=portfolio
).values_list("user__id", flat=True)
all_members = User.objects.filter(id__in=member_ids)
context["portfolio_members"] = all_members
context["portfolio_members_count"] = all_members.count()
return render(request, "portfolio_members.html")
class PortfolioNoDomainsView(NoPortfolioDomainsPermissionView, View):
"""Some users have access to the underlying portfolio, but not any domains.
This is a custom view which explains that to the user - and denotes who to contact.