mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 17:11:09 -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:
@@ -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
|
|
||||||
@@ -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"
|
||||||
|
|||||||
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
|
## 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user