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
## Architecture
## Search Flow
The park search functionality uses a combination of:
- BaseAutocomplete for search suggestions
- django-htmx for async updates
- Django filters for advanced filtering
1. **Quick Search (Suggestions)**
- Endpoint: `suggest_parks/`
- Shows up to 8 suggestions
- Uses HTMX for real-time updates
- 300ms debounce for typing
### Components
1. **Forms**
- `ParkAutocomplete`: Handles search suggestions
- `ParkSearchForm`: Integrates autocomplete with search form
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
2. **Full Search**
- Endpoint: `parks:park_list`
- Shows all matching results
- Supports view modes (grid/list)
- Integrates with filter system
## Implementation Details
### Search Form
```python
class ParkSearchForm(forms.Form):
park = forms.ModelChoiceField(
queryset=Park.objects.all(),
required=False,
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...'
}
)
)
```
### Frontend Components
- Search input with HTMX and Alpine.js integration
- Suggestions dropdown with accessibility support
- Loading indicator during searches
- View mode toggle buttons
- Filter form integration
### Autocomplete
```python
class ParkAutocomplete(BaseAutocomplete):
model = Park
search_attrs = ['name']
def get_search_results(self, search):
return (get_base_park_queryset()
.filter(name__icontains=search)
.select_related('owner')
.order_by('name'))
```
### Templates
- `park_list.html`: Main search interface
- `park_suggestions.html`: Partial for search suggestions
- `park_list_item.html`: Results display
### View Integration
```python
class ParkSearchView(TemplateView):
template_name = "parks/park_list.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_form'] = ParkSearchForm(self.request.GET)
# ... filter handling ...
return context
```
### Key Features
- Real-time suggestions
- Keyboard navigation (ESC to clear)
- ARIA attributes for accessibility
- Dark mode support
- CSRF protection
- Loading states
## 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**
- Tiered access control:
* Public basic search
* Authenticated users get autocomplete
* Protected endpoints via settings
- CSRF protection
- Input validation
## Recent Updates (2024-02-22)
1. Fixed search page loading issue:
- Removed legacy redirect in suggest_parks
- Updated search form to use HTMX properly
- Added Alpine.js for state management
- Improved suggestions UI
- Maintained view mode during search
2. **Real-time Search**
- Debounced input handling
- Instant results display
- Loading indicators
2. Security:
- CSRF protection on all forms
- Input sanitization
- 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
- Keyboard navigation support
- Screen reader compatibility
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
- Keyboard navigation
- Proper focus management
- Screen reader support

View File

@@ -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"

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
### 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

View File

@@ -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

View File

@@ -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}
)