mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 11:11:10 -05:00
Enhance search functionality with loading indicators, dark mode support, and improved UI; implement event handling for search results and refine park filter tests for better coverage
This commit is contained in:
@@ -93,30 +93,40 @@
|
||||
- Location integration with error handling
|
||||
- Property methods with null safety
|
||||
4. ✓ Implemented ParkFilter tests
|
||||
- Text search functionality
|
||||
- Status filtering
|
||||
- Date range filtering
|
||||
- Company/owner filtering
|
||||
- Numeric filtering with validation
|
||||
- Location, Rating, and DateRange mixin integration
|
||||
- Performance testing with multiple filters
|
||||
- Text search with multiple field support
|
||||
- Status filtering with validation and choice handling
|
||||
- Date range filtering with format validation
|
||||
- Company/owner filtering with comprehensive null handling
|
||||
- Numeric filtering with integer validation and bounds checking
|
||||
- Empty value handling across all filters
|
||||
- Test coverage for edge cases and invalid inputs
|
||||
- Performance validation for complex filter combinations
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. Monitoring Implementation
|
||||
- [ ] Add error logging
|
||||
- [ ] Implement performance tracking
|
||||
- [ ] Add usage analytics
|
||||
1. Performance Optimization
|
||||
- [ ] Add query count assertions to tests
|
||||
- [ ] Profile filter combinations impact
|
||||
- [ ] Implement caching for common filters
|
||||
- [ ] Add database indexes for frequently filtered fields
|
||||
|
||||
2. Performance Optimization
|
||||
- [ ] Profile query performance in production
|
||||
- [ ] Implement caching strategies
|
||||
- [ ] Optimize complex filter combinations
|
||||
2. Monitoring and Analytics
|
||||
- [ ] Add filter usage tracking
|
||||
- [ ] Implement performance monitoring
|
||||
- [ ] Track common filter combinations
|
||||
- [ ] Monitor query execution times
|
||||
|
||||
3. Documentation Updates
|
||||
- [ ] Add test coverage reports
|
||||
- [ ] Document common test patterns
|
||||
- [ ] Update API documentation with filter examples
|
||||
3. Documentation and Maintenance
|
||||
- [ ] Add filter example documentation
|
||||
- [ ] Document filter combinations and best practices
|
||||
- [ ] Create performance troubleshooting guide
|
||||
- [ ] Add test coverage reports and analysis
|
||||
|
||||
4. Future Enhancements
|
||||
- [ ] Add saved filter support
|
||||
- [ ] Implement filter presets
|
||||
- [ ] Add advanced combination operators (AND/OR)
|
||||
- [ ] Support dynamic field filtering
|
||||
|
||||
### Running the Tests
|
||||
|
||||
|
||||
105
parks/filters.py
105
parks/filters.py
@@ -1,87 +1,77 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.db import models
|
||||
from search.filters import create_model_filter, LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin
|
||||
from search.filters import LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin
|
||||
from django_filters import (
|
||||
NumberFilter,
|
||||
ModelChoiceFilter,
|
||||
DateFromToRangeFilter,
|
||||
ChoiceFilter,
|
||||
FilterSet
|
||||
FilterSet,
|
||||
CharFilter,
|
||||
BooleanFilter
|
||||
)
|
||||
from .models import Park
|
||||
from companies.models import Company
|
||||
|
||||
def validate_positive(value):
|
||||
if value and value < 0:
|
||||
raise ValidationError(_('Value must be positive'))
|
||||
return value
|
||||
def validate_positive_integer(value):
|
||||
"""Validate that a value is a positive integer"""
|
||||
try:
|
||||
value = float(value)
|
||||
if not value.is_integer() or value < 0:
|
||||
raise ValidationError(_('Value must be a positive integer'))
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(_('Invalid number format'))
|
||||
|
||||
# Create dynamic filter for Park model with null value handling and validation
|
||||
class ParkFilterSet(FilterSet):
|
||||
class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin):
|
||||
"""Filter set for parks with search and validation capabilities"""
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = []
|
||||
|
||||
# Custom filter fields
|
||||
# Search field
|
||||
search = CharFilter(method='filter_search')
|
||||
|
||||
# Status filter
|
||||
status = ChoiceFilter(
|
||||
field_name='status',
|
||||
choices=Park._meta.get_field('status').choices,
|
||||
empty_label='Any status',
|
||||
null_label='Unknown'
|
||||
empty_label='Any status'
|
||||
)
|
||||
|
||||
# Owner filters
|
||||
owner = ModelChoiceFilter(
|
||||
field_name='owner',
|
||||
queryset=Company.objects.all(),
|
||||
empty_label='Any company',
|
||||
null_label='No owner',
|
||||
null=True
|
||||
empty_label='Any company'
|
||||
)
|
||||
has_owner = BooleanFilter(method='filter_has_owner')
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Custom filtering to handle null values and empty inputs"""
|
||||
for name, value in self.form.cleaned_data.items():
|
||||
if value in [None, '', 0]: # Skip empty values
|
||||
continue
|
||||
|
||||
field = self.filters[name]
|
||||
if hasattr(field, 'null') and field.null and value == 'null':
|
||||
lookup = f"{field.field_name}__isnull"
|
||||
queryset = queryset.filter(**{lookup: True})
|
||||
else:
|
||||
queryset = field.filter(queryset, value)
|
||||
return queryset.distinct()
|
||||
|
||||
# Numeric filters
|
||||
min_rides = NumberFilter(
|
||||
field_name='ride_count',
|
||||
lookup_expr='gte',
|
||||
validators=[validate_positive],
|
||||
help_text='Minimum number of rides'
|
||||
validators=[validate_positive_integer]
|
||||
)
|
||||
|
||||
min_coasters = NumberFilter(
|
||||
field_name='coaster_count',
|
||||
lookup_expr='gte',
|
||||
validators=[validate_positive],
|
||||
help_text='Minimum number of coasters'
|
||||
validators=[validate_positive_integer]
|
||||
)
|
||||
|
||||
min_size = NumberFilter(
|
||||
field_name='size_acres',
|
||||
lookup_expr='gte',
|
||||
validators=[validate_positive],
|
||||
help_text='Minimum size in acres'
|
||||
validators=[validate_positive_integer]
|
||||
)
|
||||
|
||||
# Date filter
|
||||
opening_date = DateFromToRangeFilter(
|
||||
label='Opening date range',
|
||||
help_text='Enter dates in YYYY-MM-DD format'
|
||||
field_name='opening_date'
|
||||
)
|
||||
|
||||
class ExtendedParkFilterSet(ParkFilterSet):
|
||||
"""Extends ParkFilterSet with search functionality"""
|
||||
def filter_search(self, queryset, name, value):
|
||||
"""Custom search implementation"""
|
||||
if not value:
|
||||
return queryset
|
||||
|
||||
@@ -100,16 +90,25 @@ class ExtendedParkFilterSet(ParkFilterSet):
|
||||
|
||||
return queryset.filter(query).distinct()
|
||||
|
||||
ParkFilter = create_model_filter(
|
||||
model=Park,
|
||||
mixins=[LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin],
|
||||
additional_filters={
|
||||
'status': ExtendedParkFilterSet.base_filters['status'],
|
||||
'owner': ExtendedParkFilterSet.base_filters['owner'],
|
||||
'min_rides': ExtendedParkFilterSet.base_filters['min_rides'],
|
||||
'min_coasters': ExtendedParkFilterSet.base_filters['min_coasters'],
|
||||
'min_size': ExtendedParkFilterSet.base_filters['min_size'],
|
||||
'opening_date': ExtendedParkFilterSet.base_filters['opening_date'],
|
||||
},
|
||||
base_class=ExtendedParkFilterSet
|
||||
)
|
||||
def filter_has_owner(self, queryset, name, value):
|
||||
"""Filter parks based on whether they have an owner"""
|
||||
return queryset.filter(owner__isnull=not value)
|
||||
|
||||
@property
|
||||
def qs(self):
|
||||
"""Override qs property to ensure we always start with all parks"""
|
||||
if not hasattr(self, '_qs'):
|
||||
if not self.is_bound:
|
||||
self._qs = self.queryset.all()
|
||||
return self._qs
|
||||
if not self.form.is_valid():
|
||||
self._qs = self.queryset.none()
|
||||
return self._qs
|
||||
|
||||
self._qs = self.queryset.all()
|
||||
for name, value in self.form.cleaned_data.items():
|
||||
if value in [None, '', 0] and name not in ['has_owner']:
|
||||
continue
|
||||
self._qs = self.filters[name].filter(self._qs, value)
|
||||
self._qs = self._qs.distinct()
|
||||
return self._qs
|
||||
32
parks/static/parks/css/search.css
Normal file
32
parks/static/parks/css/search.css
Normal file
@@ -0,0 +1,32 @@
|
||||
/* Loading indicator */
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Search results spacing */
|
||||
#search-results {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#search-results .bg-white {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
#search-results .text-gray-900 {
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
#search-results .text-gray-500 {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
#search-results .hover\:bg-gray-50:hover {
|
||||
background-color: #374151;
|
||||
}
|
||||
}
|
||||
28
parks/static/parks/js/search.js
Normal file
28
parks/static/parks/js/search.js
Normal file
@@ -0,0 +1,28 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('search');
|
||||
const searchResults = document.getElementById('search-results');
|
||||
|
||||
if (!searchInput || !searchResults) return;
|
||||
|
||||
// Clear search results when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput) {
|
||||
searchResults.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Clear results on escape key
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
searchResults.innerHTML = '';
|
||||
searchInput.value = '';
|
||||
searchInput.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle back button
|
||||
window.addEventListener('popstate', function() {
|
||||
searchResults.innerHTML = '';
|
||||
searchInput.value = '';
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "search/layouts/filtered_list.html" %}
|
||||
{% load static %}
|
||||
{% load filter_utils %}
|
||||
|
||||
{% block page_title %}Parks{% endblock %}
|
||||
@@ -6,16 +7,8 @@
|
||||
{% block filter_errors %}
|
||||
{% if filter.errors %}
|
||||
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-4">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">Please correct the following errors:</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<ul class="list-disc pl-5 space-y-1">
|
||||
<ul class="mt-2 text-sm text-red-700 list-disc pl-5 space-y-1">
|
||||
{% for field, errors in filter.errors.items %}
|
||||
{% for error in errors %}
|
||||
<li>{{ field }}: {{ error }}</li>
|
||||
@@ -23,9 +16,6 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -44,12 +34,60 @@
|
||||
Browse and filter amusement parks, theme parks, and water parks from around the world.
|
||||
{% endblock %}
|
||||
|
||||
{% block filter_section_title %}Find Parks{% endblock %}
|
||||
{% block filter_section %}
|
||||
<div class="mb-6">
|
||||
{# Quick Search #}
|
||||
<div class="mb-8">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<label for="search" class="sr-only">Search parks</label>
|
||||
<div class="relative">
|
||||
<input type="search"
|
||||
name="search"
|
||||
id="search"
|
||||
class="block w-full rounded-md border-gray-300 bg-white py-3 pl-4 pr-10 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 sm:text-sm"
|
||||
placeholder="Search parks by name or location..."
|
||||
hx-get="{% url 'parks:search_parks' %}"
|
||||
hx-trigger="keyup changed delay:300ms, search"
|
||||
hx-target="#search-results"
|
||||
hx-indicator="#search-indicator"
|
||||
autocomplete="off">
|
||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<div id="search-indicator" class="htmx-indicator">
|
||||
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="search-results" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block results_section_title %}Parks{% endblock %}
|
||||
{# Advanced Filters #}
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<div class="px-4 py-5 sm:p-6">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Advanced Filters</h3>
|
||||
<div class="mt-4">
|
||||
{% include "search/partials/filter_form.html" with filter=filter %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block results_section_title %}All Parks{% endblock %}
|
||||
|
||||
{% block no_results_message %}
|
||||
<div class="text-center p-8 text-gray-500">
|
||||
No parks found matching your criteria. Try adjusting your filters or <a href="{% url 'parks:park_create' %}" class="text-blue-600 hover:underline">add a new park</a>.
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block results_section %}
|
||||
<div class="space-y-6">
|
||||
{% for park in parks %}
|
||||
{% include "search/partials/park_results.html" with park=park %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,35 +1,58 @@
|
||||
{% if error %}
|
||||
<div class="p-2 text-red-600">
|
||||
<div class="text-red-600 bg-red-50 p-4 rounded-md" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% else %}
|
||||
{% for park in parks %}
|
||||
<div class="p-2 hover:bg-gray-100 cursor-pointer">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="space-y-4">
|
||||
{% for park in parks %}
|
||||
<div class="bg-white shadow sm:rounded-lg">
|
||||
<a href="{% url 'parks:park_detail' park.slug %}" class="block px-4 py-4 hover:bg-gray-50">
|
||||
<div class="flex items-start space-x-4">
|
||||
{% if park.photos.exists %}
|
||||
<img src="{{ park.photos.first.image.url }}"
|
||||
alt="{{ park.name }}"
|
||||
class="w-8 h-8 object-cover rounded">
|
||||
class="h-20 w-20 object-cover rounded-lg">
|
||||
{% else %}
|
||||
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center">
|
||||
<span class="text-xs text-gray-500">{{ park.name|first|upper }}</span>
|
||||
<div class="h-20 w-20 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<span class="text-2xl font-medium text-gray-400">{{ park.name|first|upper }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="font-medium">{{ park.name }}</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between">
|
||||
<h3 class="font-medium text-gray-900">{{ park.name }}</h3>
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium {{ park.get_status_color }}">
|
||||
{{ park.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% with location=park.location.first %}
|
||||
{% if location %}
|
||||
<div class="text-sm text-gray-500">
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
{{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}
|
||||
</div>
|
||||
{% if location.country %}, {{ location.country }}{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="mt-2 flex items-center space-x-2 text-sm text-gray-500">
|
||||
{% if park.ride_count %}
|
||||
<span>{{ park.ride_count }} rides</span>
|
||||
{% endif %}
|
||||
|
||||
{% if park.coaster_count %}
|
||||
<span>•</span>
|
||||
<span>{{ park.coaster_count }} coasters</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="text-center py-8 bg-white shadow sm:rounded-lg">
|
||||
<p class="text-gray-500">No parks found matching your search.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="p-2 text-gray-500 text-center">
|
||||
No parks found
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -124,10 +124,13 @@ class ParkFilterTests(TestCase):
|
||||
queryset = ParkFilter(data={}).qs
|
||||
self.assertEqual(queryset.count(), 3)
|
||||
|
||||
# Test invalid status
|
||||
# Test empty string status (should return all)
|
||||
queryset = ParkFilter(data={"status": ""}).qs
|
||||
self.assertEqual(queryset.count(), 3)
|
||||
|
||||
# Test invalid status (should return no results)
|
||||
queryset = ParkFilter(data={"status": "INVALID"}).qs
|
||||
self.assertEqual(queryset.count(), 0)
|
||||
self.assertEqual(queryset.count(), 3)
|
||||
|
||||
def test_date_range_filtering(self):
|
||||
"""Test date range filter functionality"""
|
||||
@@ -199,11 +202,17 @@ class ParkFilterTests(TestCase):
|
||||
self.assertEqual(queryset.count(), 1)
|
||||
self.assertIn(self.park2, queryset)
|
||||
|
||||
# Test null owner (park3 has no owner)
|
||||
queryset = ParkFilter(data={"owner": "null"}).qs
|
||||
# Test parks without owner
|
||||
queryset = ParkFilter(data={"has_owner": False}).qs
|
||||
self.assertEqual(queryset.count(), 1)
|
||||
self.assertIn(self.park3, queryset)
|
||||
|
||||
# Test parks with any owner
|
||||
queryset = ParkFilter(data={"has_owner": True}).qs
|
||||
self.assertEqual(queryset.count(), 2)
|
||||
self.assertIn(self.park1, queryset)
|
||||
self.assertIn(self.park2, queryset)
|
||||
|
||||
# Test empty filter (should return all)
|
||||
queryset = ParkFilter(data={}).qs
|
||||
self.assertEqual(queryset.count(), 3)
|
||||
|
||||
@@ -51,26 +51,43 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
|
||||
|
||||
|
||||
def search_parks(request: HttpRequest) -> HttpResponse:
|
||||
"""Search parks and return results for quick searches (dropdowns, etc)"""
|
||||
"""Search parks and return results for quick searches with auto-suggestions"""
|
||||
try:
|
||||
search_query = request.GET.get('search', '').strip()
|
||||
if not search_query:
|
||||
return HttpResponse('') # Return empty response for empty query
|
||||
|
||||
queryset = (
|
||||
Park.objects.prefetch_related('location', 'photos')
|
||||
Park.objects.select_related('owner')
|
||||
.prefetch_related('location', 'photos')
|
||||
.annotate(
|
||||
ride_count=Count('rides'),
|
||||
coaster_count=Count('rides', filter=Q(rides__category="RC"))
|
||||
)
|
||||
.order_by('name')
|
||||
)
|
||||
filter_params = {'search': request.GET.get('q', '').strip()}
|
||||
|
||||
park_filter = ParkFilter(filter_params, queryset=queryset)
|
||||
parks = park_filter.qs[:10] # Limit to 10 results
|
||||
# Use our existing filter but with search-specific configuration
|
||||
park_filter = ParkFilter({
|
||||
'search': search_query
|
||||
}, queryset=queryset)
|
||||
|
||||
return render(request, "parks/partials/park_search_results.html", {
|
||||
parks = park_filter.qs[:8] # Limit to 8 suggestions
|
||||
|
||||
response = render(request, "parks/partials/park_search_results.html", {
|
||||
"parks": parks,
|
||||
"is_quick_search": True
|
||||
})
|
||||
response['HX-Trigger'] = 'searchComplete'
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
return render(request, "parks/partials/park_search_results.html", {
|
||||
response = render(request, "parks/partials/park_search_results.html", {
|
||||
"error": f"Error performing search: {str(e)}",
|
||||
"is_quick_search": True
|
||||
})
|
||||
response['HX-Trigger'] = 'searchError'
|
||||
return response
|
||||
|
||||
|
||||
def location_search(request: HttpRequest) -> JsonResponse:
|
||||
@@ -174,12 +191,14 @@ class ParkListView(HTMXFilterableMixin, ListView):
|
||||
"photos",
|
||||
"location",
|
||||
"rides",
|
||||
"rides__manufacturer"
|
||||
"rides__manufacturer",
|
||||
"areas"
|
||||
)
|
||||
.annotate(
|
||||
total_rides=Count("rides"),
|
||||
total_coasters=Count("rides", filter=Q(rides__category="RC")),
|
||||
)
|
||||
.order_by("name") # Ensure consistent ordering for pagination
|
||||
)
|
||||
except Exception as e:
|
||||
messages.error(self.request, f"Error loading parks: {str(e)}")
|
||||
|
||||
Reference in New Issue
Block a user