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:
pacnpal
2025-02-23 10:50:25 -05:00
parent 02e4b82beb
commit 1ca1362fee
6 changed files with 218 additions and 146 deletions

View File

@@ -1,105 +1,69 @@
# Park Search Implementation # Park Search Implementation
## Architecture ## Search Flow
The park search functionality uses a combination of: 1. **Quick Search (Suggestions)**
- BaseAutocomplete for search suggestions - Endpoint: `suggest_parks/`
- django-htmx for async updates - Shows up to 8 suggestions
- Django filters for advanced filtering - Uses HTMX for real-time updates
- 300ms debounce for typing
### Components 2. **Full Search**
- Endpoint: `parks:park_list`
1. **Forms** - Shows all matching results
- `ParkAutocomplete`: Handles search suggestions - Supports view modes (grid/list)
- `ParkSearchForm`: Integrates autocomplete with search form - Integrates with filter system
2. **Views**
- `ParkSearchView`: Class-based view handling search and filters
- `suggest_parks`: Legacy endpoint maintained for backward compatibility
3. **Templates**
- Simplified search UI using autocomplete widget
- Integrated loading indicators
- Filter form for additional search criteria
## Implementation Details ## Implementation Details
### Search Form ### Frontend Components
```python - Search input with HTMX and Alpine.js integration
class ParkSearchForm(forms.Form): - Suggestions dropdown with accessibility support
park = forms.ModelChoiceField( - Loading indicator during searches
queryset=Park.objects.all(), - View mode toggle buttons
required=False, - Filter form integration
widget=AutocompleteWidget(
ac_class=ParkAutocomplete,
attrs={
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
'placeholder': 'Search parks...'
}
)
)
```
### Autocomplete ### Templates
```python - `park_list.html`: Main search interface
class ParkAutocomplete(BaseAutocomplete): - `park_suggestions.html`: Partial for search suggestions
model = Park - `park_list_item.html`: Results display
search_attrs = ['name']
def get_search_results(self, search):
return (get_base_park_queryset()
.filter(name__icontains=search)
.select_related('owner')
.order_by('name'))
```
### View Integration ### Key Features
```python - Real-time suggestions
class ParkSearchView(TemplateView): - Keyboard navigation (ESC to clear)
template_name = "parks/park_list.html" - ARIA attributes for accessibility
- Dark mode support
def get_context_data(self, **kwargs): - CSRF protection
context = super().get_context_data(**kwargs) - Loading states
context['search_form'] = ParkSearchForm(self.request.GET)
# ... filter handling ...
return context
```
## Features ### Search Flow
1. User types in search box
2. After 300ms debounce, HTMX sends request
3. Server returns suggestion list
4. User selects item
5. Form submits to main list view with filter
6. Results update while maintaining view mode
1. **Security** ## Recent Updates (2024-02-22)
- Tiered access control: 1. Fixed search page loading issue:
* Public basic search - Removed legacy redirect in suggest_parks
* Authenticated users get autocomplete - Updated search form to use HTMX properly
* Protected endpoints via settings - Added Alpine.js for state management
- CSRF protection - Improved suggestions UI
- Input validation - Maintained view mode during search
2. **Real-time Search** 2. Security:
- Debounced input handling - CSRF protection on all forms
- Instant results display - Input sanitization
- Loading indicators - Proper parameter handling
3. **Accessibility** 3. Performance:
- 300ms debounce on typing
- Limit suggestions to 8 items
- Efficient query optimization
4. Accessibility:
- ARIA labels and roles - ARIA labels and roles
- Keyboard navigation support - Keyboard navigation
- Screen reader compatibility - Proper focus management
- Screen reader support
4. **Integration**
- Works with existing filter system
- Maintains view mode selection
- Preserves URL state
## Performance Considerations
- Prefetch related owner data
- Uses base queryset optimizations
- Debounced search requests
- Proper index usage on name field
## Future Improvements
- Consider adding full-text search
- Implement result caching
- Add geographic search capabilities
- Enhance filter integration

View File

@@ -47,15 +47,38 @@
{% block filter_section %} {% block filter_section %}
<div class="mb-6"> <div class="mb-6">
<div class="max-w-3xl mx-auto relative mb-8"> <div class="max-w-3xl mx-auto relative mb-8">
<div class="w-full relative"> <div class="w-full relative" x-data="{ query: '', selectedId: null }">
<form hx-get="{% url 'parks:park_list' %}" <form hx-get="{% url 'parks:suggest_parks' %}"
hx-target="#park-results" hx-target="#search-results"
hx-push-url="true" hx-trigger="input changed delay:300ms"
hx-trigger="change from:.park-search"> 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 %} {% 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> </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 --> <!-- Loading indicator -->
<div id="search-indicator" <div id="search-indicator"
class="htmx-indicator absolute right-3 top-3" class="htmx-indicator absolute right-3 top-3"

View 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 %}

View File

@@ -24,24 +24,63 @@ uv run pytest --cov=parks parks/tests/
## Test Coverage ## Test Coverage
### Unit Tests ### Search Functionality Tests
- `test_autocomplete_results`: Validates search result filtering - `test_autocomplete_results`: Validates real-time suggestion filtering
- `test_search_form_valid`: Ensures form validation works - `test_search_form_valid`: Ensures form validation works
- `test_autocomplete_class`: Checks autocomplete configuration - `test_autocomplete_class`: Verifies autocomplete configuration
- `test_search_with_filters`: Verifies filter integration - `test_search_with_filters`: Tests filter integration
### Integration Tests ### Integration Tests
- `test_empty_search`: Tests default behavior - `test_empty_search`: Tests default behavior
- `test_partial_match_search`: Validates partial text matching - `test_partial_match_search`: Validates partial text matching
- `test_htmx_request_handling`: Ensures HTMX compatibility - `test_htmx_request_handling`: Ensures HTMX compatibility
- `test_view_mode_persistence`: Checks view state management - `test_view_mode_persistence`: Verifies view state preservation
- `test_unauthenticated_access`: Verifies authentication requirements
### Security Tests ### Performance Tests
Parks search implements a tiered access approach: - `test_suggestion_limit`: Verifies 8-item limit on suggestions
- Basic search is public - `test_search_debounce`: Confirms proper debounce headers
- Autocomplete requires authentication
- Configuration set in settings.py: `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True` ### 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 ## Configuration

View File

@@ -5,6 +5,7 @@ from django.test import Client
from parks.models import Park from parks.models import Park
from parks.forms import ParkAutocomplete, ParkSearchForm from parks.forms import ParkAutocomplete, ParkSearchForm
@pytest.mark.django_db @pytest.mark.django_db
class TestParkSearch: class TestParkSearch:
def test_autocomplete_results(self, client: Client): def test_autocomplete_results(self, client: Client):
@@ -13,11 +14,11 @@ class TestParkSearch:
park1 = Park.objects.create(name="Test Park") park1 = Park.objects.create(name="Test Park")
park2 = Park.objects.create(name="Another Park") park2 = Park.objects.create(name="Another Park")
park3 = Park.objects.create(name="Test Garden") park3 = Park.objects.create(name="Test Garden")
# Get autocomplete results # Get autocomplete results
url = reverse('parks:park_list') url = reverse('parks:suggest_parks')
response = client.get(url, {'park': 'Test'}) response = client.get(url, {'search': 'Test'})
# Check response # Check response
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
@@ -27,7 +28,7 @@ class TestParkSearch:
def test_search_form_valid(self): def test_search_form_valid(self):
"""Test ParkSearchForm validation""" """Test ParkSearchForm validation"""
form = ParkSearchForm(data={'park': ''}) form = ParkSearchForm(data={})
assert form.is_valid() assert form.is_valid()
def test_autocomplete_class(self): def test_autocomplete_class(self):
@@ -39,14 +40,14 @@ class TestParkSearch:
def test_search_with_filters(self, client: Client): def test_search_with_filters(self, client: Client):
"""Test search works with filters""" """Test search works with filters"""
park = Park.objects.create(name="Test Park", status="OPERATING") park = Park.objects.create(name="Test Park", status="OPERATING")
# Search with status filter # Search with status filter
url = reverse('parks:park_list') url = reverse('parks:park_list')
response = client.get(url, { response = client.get(url, {
'park': str(park.pk), 'park': str(park.pk),
'status': 'OPERATING' 'status': 'OPERATING'
}) })
assert response.status_code == 200 assert response.status_code == 200
assert park.name in response.content.decode() assert park.name in response.content.decode()
@@ -54,10 +55,10 @@ class TestParkSearch:
"""Test empty search returns all parks""" """Test empty search returns all parks"""
Park.objects.create(name="Test Park") Park.objects.create(name="Test Park")
Park.objects.create(name="Another Park") Park.objects.create(name="Another Park")
url = reverse('parks:park_list') url = reverse('parks:park_list')
response = client.get(url) response = client.get(url)
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
assert "Test Park" in content assert "Test Park" in content
@@ -67,10 +68,10 @@ class TestParkSearch:
"""Test partial matching in search""" """Test partial matching in search"""
Park.objects.create(name="Adventure World") Park.objects.create(name="Adventure World")
Park.objects.create(name="Water Adventure") Park.objects.create(name="Water Adventure")
url = reverse('parks:park_list') url = reverse('parks:suggest_parks')
response = client.get(url, {'park': 'Adv'}) response = client.get(url, {'search': 'Adv'})
assert response.status_code == 200 assert response.status_code == 200
content = response.content.decode() content = response.content.decode()
assert "Adventure World" in content assert "Adventure World" in content
@@ -79,41 +80,49 @@ class TestParkSearch:
def test_htmx_request_handling(self, client: Client): def test_htmx_request_handling(self, client: Client):
"""Test HTMX-specific request handling""" """Test HTMX-specific request handling"""
Park.objects.create(name="Test Park") Park.objects.create(name="Test Park")
url = reverse('parks:park_list') url = reverse('parks:suggest_parks')
response = client.get( response = client.get(
url, url,
{'park': 'Test'}, {'search': 'Test'},
HTTP_HX_REQUEST='true' HTTP_HX_REQUEST='true'
) )
assert response.status_code == 200 assert response.status_code == 200
assert "Test Park" in response.content.decode() assert "Test Park" in response.content.decode()
def test_view_mode_persistence(self, client: Client): def test_view_mode_persistence(self, client: Client):
"""Test view mode is maintained during search""" """Test view mode is maintained during search"""
Park.objects.create(name="Test Park") Park.objects.create(name="Test Park")
url = reverse('parks:park_list') url = reverse('parks:park_list')
response = client.get(url, { response = client.get(url, {
'park': 'Test', 'park': 'Test',
'view_mode': 'list' 'view_mode': 'list'
}) })
assert response.status_code == 200 assert response.status_code == 200
assert 'data-view-mode="list"' in response.content.decode() assert 'data-view-mode="list"' in response.content.decode()
def test_unauthenticated_access(self, client: Client): def test_suggestion_limit(self, client: Client):
"""Test that unauthorized users can access search but not autocomplete""" """Test that suggestions are limited to 8 items"""
park = Park.objects.create(name="Test Park") # Create 10 parks
for i in range(10):
# Regular search should work Park.objects.create(name=f"Test Park {i}")
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
url = reverse('parks:suggest_parks') url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Test'}) 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

View File

@@ -34,10 +34,17 @@ class ParkSearchView(TemplateView):
return context return context
def suggest_parks(request: HttpRequest) -> HttpResponse: 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() query = request.GET.get('search', '').strip()
if query: if not query:
return JsonResponse({ return HttpResponse('')
'redirect': f"{reverse('parks:park_list')}?park_name={query}"
}) queryset = get_base_park_queryset()
return HttpResponse('') 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}
)