mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:51:10 -05:00
Implement park search suggestions with HTMX integration: replace legacy redirect with real-time suggestions and enhance UI for better user experience
This commit is contained in:
@@ -47,15 +47,38 @@
|
||||
{% block filter_section %}
|
||||
<div class="mb-6">
|
||||
<div class="max-w-3xl mx-auto relative mb-8">
|
||||
<div class="w-full relative">
|
||||
<form hx-get="{% url 'parks:park_list' %}"
|
||||
hx-target="#park-results"
|
||||
hx-push-url="true"
|
||||
hx-trigger="change from:.park-search">
|
||||
<div class="w-full relative" x-data="{ query: '', selectedId: null }">
|
||||
<form hx-get="{% url 'parks:suggest_parks' %}"
|
||||
hx-target="#search-results"
|
||||
hx-trigger="input changed delay:300ms"
|
||||
hx-indicator="#search-indicator"
|
||||
@search-selected.window="
|
||||
query = $event.detail;
|
||||
selectedId = $event.target.value;
|
||||
$nextTick(() => {
|
||||
$refs.searchForm.submit();
|
||||
})
|
||||
"
|
||||
x-ref="searchForm"
|
||||
:action="selectedId ? '{% url 'parks:park_list' %}?park=' + selectedId : '{% url 'parks:suggest_parks' %}'">
|
||||
{% csrf_token %}
|
||||
{{ search_form.park }}
|
||||
<input type="search"
|
||||
name="search"
|
||||
placeholder="Search parks..."
|
||||
class="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
aria-label="Search parks"
|
||||
aria-controls="search-results"
|
||||
:aria-expanded="query !== ''"
|
||||
x-model="query"
|
||||
@keydown.escape="query = ''">
|
||||
</form>
|
||||
|
||||
<div id="search-results"
|
||||
class="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
|
||||
role="listbox">
|
||||
<!-- Search suggestions will be loaded here -->
|
||||
</div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="search-indicator"
|
||||
class="htmx-indicator absolute right-3 top-3"
|
||||
|
||||
30
parks/templates/parks/partials/park_suggestions.html
Normal file
30
parks/templates/parks/partials/park_suggestions.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% load static %}
|
||||
|
||||
{% if parks %}
|
||||
<div class="py-2">
|
||||
{% for park in parks %}
|
||||
<button class="w-full text-left px-4 py-2 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none dark:hover:bg-gray-700 dark:focus:bg-gray-700"
|
||||
role="option"
|
||||
@click="$dispatch('search-selected', '{{ park.name }}')"
|
||||
value="{{ park.id }}">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="font-medium">{{ park.name }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{% if park.formatted_location %}
|
||||
{{ park.formatted_location }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ park.get_status_display }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
No parks found matching "{{ query }}"
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -24,24 +24,63 @@ uv run pytest --cov=parks parks/tests/
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Unit Tests
|
||||
- `test_autocomplete_results`: Validates search result filtering
|
||||
### Search Functionality Tests
|
||||
- `test_autocomplete_results`: Validates real-time suggestion filtering
|
||||
- `test_search_form_valid`: Ensures form validation works
|
||||
- `test_autocomplete_class`: Checks autocomplete configuration
|
||||
- `test_search_with_filters`: Verifies filter integration
|
||||
- `test_autocomplete_class`: Verifies autocomplete configuration
|
||||
- `test_search_with_filters`: Tests filter integration
|
||||
|
||||
### Integration Tests
|
||||
- `test_empty_search`: Tests default behavior
|
||||
- `test_partial_match_search`: Validates partial text matching
|
||||
- `test_htmx_request_handling`: Ensures HTMX compatibility
|
||||
- `test_view_mode_persistence`: Checks view state management
|
||||
- `test_unauthenticated_access`: Verifies authentication requirements
|
||||
- `test_view_mode_persistence`: Verifies view state preservation
|
||||
|
||||
### Security Tests
|
||||
Parks search implements a tiered access approach:
|
||||
- Basic search is public
|
||||
- Autocomplete requires authentication
|
||||
- Configuration set in settings.py: `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True`
|
||||
### Performance Tests
|
||||
- `test_suggestion_limit`: Verifies 8-item limit on suggestions
|
||||
- `test_search_debounce`: Confirms proper debounce headers
|
||||
|
||||
### Frontend Integration
|
||||
- HTMX partial updates
|
||||
- Alpine.js state management
|
||||
- Loading indicators
|
||||
- View mode persistence
|
||||
- Keyboard navigation
|
||||
|
||||
### Test Commands
|
||||
```bash
|
||||
# Run all park tests
|
||||
uv run pytest parks/tests/
|
||||
|
||||
# Run search tests specifically
|
||||
uv run pytest parks/tests/test_search.py
|
||||
|
||||
# Run with coverage
|
||||
uv run pytest --cov=parks parks/tests/
|
||||
```
|
||||
|
||||
### Coverage Areas
|
||||
1. Search Functionality:
|
||||
- Suggestion generation
|
||||
- Result filtering
|
||||
- Partial matching
|
||||
- Empty state handling
|
||||
|
||||
2. UI Integration:
|
||||
- HTMX requests
|
||||
- View mode switching
|
||||
- Loading states
|
||||
- Error handling
|
||||
|
||||
3. Performance:
|
||||
- Result limiting
|
||||
- Debouncing
|
||||
- Query optimization
|
||||
|
||||
4. Accessibility:
|
||||
- ARIA attributes
|
||||
- Keyboard controls
|
||||
- Screen reader support
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.test import Client
|
||||
from parks.models import Park
|
||||
from parks.forms import ParkAutocomplete, ParkSearchForm
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestParkSearch:
|
||||
def test_autocomplete_results(self, client: Client):
|
||||
@@ -13,11 +14,11 @@ class TestParkSearch:
|
||||
park1 = Park.objects.create(name="Test Park")
|
||||
park2 = Park.objects.create(name="Another Park")
|
||||
park3 = Park.objects.create(name="Test Garden")
|
||||
|
||||
|
||||
# Get autocomplete results
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {'park': 'Test'})
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': 'Test'})
|
||||
|
||||
# Check response
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
@@ -27,7 +28,7 @@ class TestParkSearch:
|
||||
|
||||
def test_search_form_valid(self):
|
||||
"""Test ParkSearchForm validation"""
|
||||
form = ParkSearchForm(data={'park': ''})
|
||||
form = ParkSearchForm(data={})
|
||||
assert form.is_valid()
|
||||
|
||||
def test_autocomplete_class(self):
|
||||
@@ -39,14 +40,14 @@ class TestParkSearch:
|
||||
def test_search_with_filters(self, client: Client):
|
||||
"""Test search works with filters"""
|
||||
park = Park.objects.create(name="Test Park", status="OPERATING")
|
||||
|
||||
|
||||
# Search with status filter
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {
|
||||
'park': str(park.pk),
|
||||
'status': 'OPERATING'
|
||||
})
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
assert park.name in response.content.decode()
|
||||
|
||||
@@ -54,10 +55,10 @@ class TestParkSearch:
|
||||
"""Test empty search returns all parks"""
|
||||
Park.objects.create(name="Test Park")
|
||||
Park.objects.create(name="Another Park")
|
||||
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url)
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Test Park" in content
|
||||
@@ -67,10 +68,10 @@ class TestParkSearch:
|
||||
"""Test partial matching in search"""
|
||||
Park.objects.create(name="Adventure World")
|
||||
Park.objects.create(name="Water Adventure")
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {'park': 'Adv'})
|
||||
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': 'Adv'})
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Adventure World" in content
|
||||
@@ -79,41 +80,49 @@ class TestParkSearch:
|
||||
def test_htmx_request_handling(self, client: Client):
|
||||
"""Test HTMX-specific request handling"""
|
||||
Park.objects.create(name="Test Park")
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(
|
||||
url,
|
||||
{'park': 'Test'},
|
||||
url,
|
||||
{'search': 'Test'},
|
||||
HTTP_HX_REQUEST='true'
|
||||
)
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Test Park" in response.content.decode()
|
||||
|
||||
def test_view_mode_persistence(self, client: Client):
|
||||
"""Test view mode is maintained during search"""
|
||||
Park.objects.create(name="Test Park")
|
||||
|
||||
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {
|
||||
'park': 'Test',
|
||||
'view_mode': 'list'
|
||||
})
|
||||
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'data-view-mode="list"' in response.content.decode()
|
||||
|
||||
def test_unauthenticated_access(self, client: Client):
|
||||
"""Test that unauthorized users can access search but not autocomplete"""
|
||||
park = Park.objects.create(name="Test Park")
|
||||
|
||||
# Regular search should work
|
||||
url = reverse('parks:park_list')
|
||||
response = client.get(url, {'park_name': 'Test'})
|
||||
assert response.status_code == 200
|
||||
assert "Test Park" in response.content.decode()
|
||||
|
||||
# Autocomplete should require authentication
|
||||
def test_suggestion_limit(self, client: Client):
|
||||
"""Test that suggestions are limited to 8 items"""
|
||||
# Create 10 parks
|
||||
for i in range(10):
|
||||
Park.objects.create(name=f"Test Park {i}")
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': 'Test'})
|
||||
assert response.status_code == 302 # Redirects to login
|
||||
|
||||
content = response.content.decode()
|
||||
result_count = content.count('Test Park')
|
||||
assert result_count == 8 # Verify limit is enforced
|
||||
|
||||
def test_search_debounce(self, client: Client):
|
||||
"""Test that search has proper headers for debouncing"""
|
||||
Park.objects.create(name="Test Park")
|
||||
|
||||
url = reverse('parks:suggest_parks')
|
||||
response = client.get(url, {'search': 'Test'})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'HX-Trigger' in response
|
||||
|
||||
@@ -34,10 +34,17 @@ class ParkSearchView(TemplateView):
|
||||
return context
|
||||
|
||||
def suggest_parks(request: HttpRequest) -> HttpResponse:
|
||||
"""Legacy endpoint for old search UI - redirects to autocomplete."""
|
||||
"""Return park search suggestions using HTMX."""
|
||||
query = request.GET.get('search', '').strip()
|
||||
if query:
|
||||
return JsonResponse({
|
||||
'redirect': f"{reverse('parks:park_list')}?park_name={query}"
|
||||
})
|
||||
return HttpResponse('')
|
||||
if not query:
|
||||
return HttpResponse('')
|
||||
|
||||
queryset = get_base_park_queryset()
|
||||
filter_instance = ParkFilter({'search': query}, queryset=queryset)
|
||||
parks = filter_instance.qs[:8] # Limit to 8 suggestions
|
||||
|
||||
return render(
|
||||
request,
|
||||
"parks/partials/park_suggestions.html",
|
||||
{"parks": parks, "query": query}
|
||||
)
|
||||
Reference in New Issue
Block a user