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): ### Key Features
return (get_base_park_queryset() - Real-time suggestions
.filter(name__icontains=search) - Keyboard navigation (ESC to clear)
.select_related('owner') - ARIA attributes for accessibility
.order_by('name')) - Dark mode support
``` - CSRF protection
- Loading states
### View Integration ### Search Flow
```python 1. User types in search box
class ParkSearchView(TemplateView): 2. After 300ms debounce, HTMX sends request
template_name = "parks/park_list.html" 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
def get_context_data(self, **kwargs): ## Recent Updates (2024-02-22)
context = super().get_context_data(**kwargs) 1. Fixed search page loading issue:
context['search_form'] = ParkSearchForm(self.request.GET) - Removed legacy redirect in suggest_parks
# ... filter handling ... - Updated search form to use HTMX properly
return context - Added Alpine.js for state management
``` - Improved suggestions UI
- Maintained view mode during search
## Features 2. Security:
- CSRF protection on all forms
- Input sanitization
- Proper parameter handling
1. **Security** 3. Performance:
- Tiered access control: - 300ms debounce on typing
* Public basic search - Limit suggestions to 8 items
* Authenticated users get autocomplete - Efficient query optimization
* Protected endpoints via settings
- CSRF protection
- Input validation
2. **Real-time Search** 4. Accessibility:
- Debounced input handling
- Instant results display
- Loading indicators
3. **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):
@@ -15,8 +16,8 @@ class TestParkSearch:
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
@@ -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):
@@ -68,8 +69,8 @@ class TestParkSearch:
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()
@@ -80,10 +81,10 @@ class TestParkSearch:
"""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'
) )
@@ -103,17 +104,25 @@ class TestParkSearch:
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):
Park.objects.create(name=f"Test Park {i}")
# 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
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({
'redirect': f"{reverse('parks:park_list')}?park_name={query}"
})
return HttpResponse('') 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}
)