Merge pull request #2739 from cisagov/dk/2593-domain-request-search-bar

#2593: Domain request search bar and filter - [DK]
This commit is contained in:
zandercymatics 2024-09-19 08:24:36 -06:00 committed by GitHub
commit 8adb33b430
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 975 additions and 735 deletions

File diff suppressed because it is too large Load diff

View file

@ -23,13 +23,21 @@
</svg>
Reset
</button>
{% if portfolio %}
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name or creator</label>
{% else %}
<label class="usa-sr-only" for="domain-requests__search-field">Search by domain name</label>
{% endif %}
<input
class="usa-input"
id="domain-requests__search-field"
type="search"
name="search"
{% if portfolio %}
placeholder="Search by domain name or creator"
{% else %}
placeholder="Search by domain name"
{% endif %}
/>
<button class="usa-button" type="submit" id="domain-requests__search-field-submit">
<img
@ -42,6 +50,125 @@
</section>
</div>
</div>
{% if portfolio %}
<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-started"
type="checkbox"
name="filter-status"
value="started"
/>
<label class="usa-checkbox__label" for="filter-status-started"
>Started</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-submitted"
type="checkbox"
name="filter-status"
value="submitted"
/>
<label class="usa-checkbox__label" for="filter-status-submitted"
>Submitted</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-in-review"
type="checkbox"
name="filter-status"
value="in review"
/>
<label class="usa-checkbox__label" for="filter-status-in-review"
>In review</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-action-needed"
type="checkbox"
name="filter-status"
value="action needed"
/>
<label class="usa-checkbox__label" for="filter-status-action-needed"
>Action needed</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-rejected"
type="checkbox"
name="filter-status"
value="rejected"
/>
<label class="usa-checkbox__label" for="filter-status-rejected"
>Rejected</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-withdrawn"
type="checkbox"
name="filter-status"
value="withdrawn"
/>
<label class="usa-checkbox__label" for="filter-status-withdrawn"
>Withdrawn</label
>
</div>
<div class="usa-checkbox">
<input
class="usa-checkbox__input"
id="filter-status-ineligible"
type="checkbox"
name="filter-status"
value="ineligible"
/>
<label class="usa-checkbox__label" for="filter-status-ineligible"
>Ineligible</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 domain-requests__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 %}
<div class="domain-requests__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 domain-requests__table">
<caption class="sr-only">Your domain requests</caption>

View file

@ -64,7 +64,7 @@
aria-expanded="false"
aria-controls="filter-status"
>
<span class="domain__filter-indicator text-bold display-none"></span> 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>

View file

@ -458,3 +458,81 @@ class GetRequestsJsonTest(TestWithUser, WebTest):
# Ensure no approved requests are included
for domain_request in data["domain_requests"]:
self.assertNotEqual(domain_request["status"], DomainRequest.DomainRequestStatus.APPROVED)
def test_search(self):
"""Tests our search functionality. We expect that search filters on creator only when we are in a portfolio"""
# Test search for domain name
response = self.app.get(reverse("get_domain_requests_json"), {"search_term": "lamb"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domain_requests"]), 1)
requested_domain = data["domain_requests"][0]["requested_domain"]
self.assertEqual(requested_domain, "lamb-chops.gov")
# Test search for 'New domain request'
response = self.app.get(reverse("get_domain_requests_json"), {"search_term": "new domain"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(any(req["requested_domain"] is None for req in data["domain_requests"]))
# Test search with portfolio (including creator search)
self.client.force_login(self.user)
with override_flag("organization_feature", active=True), override_flag("organization_requests", active=True):
user_perm, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
response = self.app.get(
reverse("get_domain_requests_json"), {"search_term": "info", "portfolio": self.portfolio.id}
)
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(any(req["creator"].startswith("info") for req in data["domain_requests"]))
# Test search without portfolio (should not search on creator)
with override_flag("organization_feature", active=False), override_flag("organization_requests", active=False):
user_perm.delete()
response = self.app.get(reverse("get_domain_requests_json"), {"search_term": "info"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domain_requests"]), 0)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_status_filter(self):
"""Test that status filtering works properly"""
# Test a single status
response = self.app.get(reverse("get_domain_requests_json"), {"status": "started"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(all(req["status"] == "Started" for req in data["domain_requests"]))
# Test an invalid status
response = self.app.get(reverse("get_domain_requests_json"), {"status": "approved"})
self.assertEqual(response.status_code, 200)
data = response.json
self.assertEqual(len(data["domain_requests"]), 0)
@override_flag("organization_feature", active=True)
@override_flag("organization_requests", active=True)
def test_combined_filtering_and_sorting(self):
"""Test that combining filters and sorting works properly"""
user_perm, _ = UserPortfolioPermission.objects.get_or_create(
user=self.user,
portfolio=self.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
)
self.client.force_login(self.user)
response = self.app.get(
reverse("get_domain_requests_json"),
{"search_term": "beef", "status": "started", "portfolio": self.portfolio.id},
)
self.assertEqual(response.status_code, 200)
data = response.json
self.assertTrue(all("beef" in req["requested_domain"] for req in data["domain_requests"]))
self.assertTrue(all(req["status"] == "Started" for req in data["domain_requests"]))
created_at_dates = [req["created_at"] for req in data["domain_requests"]]
self.assertEqual(created_at_dates, sorted(created_at_dates, reverse=True))
user_perm.delete()

View file

@ -20,6 +20,7 @@ def get_domain_requests_json(request):
unfiltered_total = objects.count()
objects = apply_search(objects, request)
objects = apply_status_filter(objects, request)
objects = apply_sorting(objects, request)
paginator = Paginator(objects, 10)
@ -63,6 +64,7 @@ def get_domain_request_ids_from_request(request):
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()
@ -75,11 +77,34 @@ def apply_search(queryset, request):
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'