diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js
index 027ef4344..918e2c451 100644
--- a/src/registrar/assets/js/get-gov.js
+++ b/src/registrar/assets/js/get-gov.js
@@ -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 = `
+
+ ${suborganization}
+
+ `
+ }
+
+ row.innerHTML = `
+
+ ${domain.name}
+
+
+ ${expirationDateFormatted}
+
+
+ ${domain.state_display}
+
+
+
+
+ ${markupForSuborganizationRow}
+
+
+
+
+
+ ${domain.action_label} ${domain.name}
+
+
+ `;
+ 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
*/
diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html
new file mode 100644
index 000000000..980a30179
--- /dev/null
+++ b/src/registrar/templates/includes/members_table.html
@@ -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 %}
+{{url}}
+
+
+ {% if portfolio %}
+
+
+
+
+ {% endif %}
+
+
+
+
+
You don't have any members.
+
+
+
+
+
+
diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html
new file mode 100644
index 000000000..f2d0b5650
--- /dev/null
+++ b/src/registrar/templates/portfolio_members.html
@@ -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 %}
+
+
+
+ {% include "includes/members_table.html" with portfolio=portfolio portfolio_members_count=portfolio_members_count %}
+
+{% endblock %}
diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py
new file mode 100644
index 000000000..de35d56b7
--- /dev/null
+++ b/src/registrar/views/portfolio_members_json.py
@@ -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),
+# }
diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py
index 885dca636..4037a72b8 100644
--- a/src/registrar/views/portfolios.py
+++ b/src/registrar/views/portfolios.py
@@ -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.