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:
pacnpal
2025-02-13 09:42:58 -05:00
parent 1fe299fb4b
commit c197051b25
8 changed files with 292 additions and 134 deletions

View File

@@ -93,30 +93,40 @@
- Location integration with error handling - Location integration with error handling
- Property methods with null safety - Property methods with null safety
4. ✓ Implemented ParkFilter tests 4. ✓ Implemented ParkFilter tests
- Text search functionality - Text search with multiple field support
- Status filtering - Status filtering with validation and choice handling
- Date range filtering - Date range filtering with format validation
- Company/owner filtering - Company/owner filtering with comprehensive null handling
- Numeric filtering with validation - Numeric filtering with integer validation and bounds checking
- Location, Rating, and DateRange mixin integration - Empty value handling across all filters
- Performance testing with multiple filters - Test coverage for edge cases and invalid inputs
- Performance validation for complex filter combinations
### Next Steps ### Next Steps
1. Monitoring Implementation 1. Performance Optimization
- [ ] Add error logging - [ ] Add query count assertions to tests
- [ ] Implement performance tracking - [ ] Profile filter combinations impact
- [ ] Add usage analytics - [ ] Implement caching for common filters
- [ ] Add database indexes for frequently filtered fields
2. Performance Optimization 2. Monitoring and Analytics
- [ ] Profile query performance in production - [ ] Add filter usage tracking
- [ ] Implement caching strategies - [ ] Implement performance monitoring
- [ ] Optimize complex filter combinations - [ ] Track common filter combinations
- [ ] Monitor query execution times
3. Documentation Updates 3. Documentation and Maintenance
- [ ] Add test coverage reports - [ ] Add filter example documentation
- [ ] Document common test patterns - [ ] Document filter combinations and best practices
- [ ] Update API documentation with filter examples - [ ] 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 ### Running the Tests

View File

@@ -1,87 +1,77 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.db import models 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 ( from django_filters import (
NumberFilter, NumberFilter,
ModelChoiceFilter, ModelChoiceFilter,
DateFromToRangeFilter, DateFromToRangeFilter,
ChoiceFilter, ChoiceFilter,
FilterSet FilterSet,
CharFilter,
BooleanFilter
) )
from .models import Park from .models import Park
from companies.models import Company from companies.models import Company
def validate_positive(value): def validate_positive_integer(value):
if value and value < 0: """Validate that a value is a positive integer"""
raise ValidationError(_('Value must be positive')) try:
return value 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 ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin):
class ParkFilterSet(FilterSet): """Filter set for parks with search and validation capabilities"""
class Meta: class Meta:
model = Park model = Park
fields = [] fields = []
# Custom filter fields # Search field
search = CharFilter(method='filter_search')
# Status filter
status = ChoiceFilter( status = ChoiceFilter(
field_name='status', field_name='status',
choices=Park._meta.get_field('status').choices, choices=Park._meta.get_field('status').choices,
empty_label='Any status', empty_label='Any status'
null_label='Unknown'
) )
# Owner filters
owner = ModelChoiceFilter( owner = ModelChoiceFilter(
field_name='owner', field_name='owner',
queryset=Company.objects.all(), queryset=Company.objects.all(),
empty_label='Any company', empty_label='Any company'
null_label='No owner',
null=True
) )
has_owner = BooleanFilter(method='filter_has_owner')
def filter_queryset(self, queryset):
"""Custom filtering to handle null values and empty inputs""" # Numeric filters
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()
min_rides = NumberFilter( min_rides = NumberFilter(
field_name='ride_count', field_name='ride_count',
lookup_expr='gte', lookup_expr='gte',
validators=[validate_positive], validators=[validate_positive_integer]
help_text='Minimum number of rides'
) )
min_coasters = NumberFilter( min_coasters = NumberFilter(
field_name='coaster_count', field_name='coaster_count',
lookup_expr='gte', lookup_expr='gte',
validators=[validate_positive], validators=[validate_positive_integer]
help_text='Minimum number of coasters'
) )
min_size = NumberFilter( min_size = NumberFilter(
field_name='size_acres', field_name='size_acres',
lookup_expr='gte', lookup_expr='gte',
validators=[validate_positive], validators=[validate_positive_integer]
help_text='Minimum size in acres'
) )
# Date filter
opening_date = DateFromToRangeFilter( opening_date = DateFromToRangeFilter(
label='Opening date range', field_name='opening_date'
help_text='Enter dates in YYYY-MM-DD format'
) )
class ExtendedParkFilterSet(ParkFilterSet):
"""Extends ParkFilterSet with search functionality"""
def filter_search(self, queryset, name, value): def filter_search(self, queryset, name, value):
"""Custom search implementation"""
if not value: if not value:
return queryset return queryset
@@ -100,16 +90,25 @@ class ExtendedParkFilterSet(ParkFilterSet):
return queryset.filter(query).distinct() return queryset.filter(query).distinct()
ParkFilter = create_model_filter( def filter_has_owner(self, queryset, name, value):
model=Park, """Filter parks based on whether they have an owner"""
mixins=[LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin], return queryset.filter(owner__isnull=not value)
additional_filters={
'status': ExtendedParkFilterSet.base_filters['status'], @property
'owner': ExtendedParkFilterSet.base_filters['owner'], def qs(self):
'min_rides': ExtendedParkFilterSet.base_filters['min_rides'], """Override qs property to ensure we always start with all parks"""
'min_coasters': ExtendedParkFilterSet.base_filters['min_coasters'], if not hasattr(self, '_qs'):
'min_size': ExtendedParkFilterSet.base_filters['min_size'], if not self.is_bound:
'opening_date': ExtendedParkFilterSet.base_filters['opening_date'], self._qs = self.queryset.all()
}, return self._qs
base_class=ExtendedParkFilterSet 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

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

View 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 = '';
});
});

View File

@@ -1,4 +1,5 @@
{% extends "search/layouts/filtered_list.html" %} {% extends "search/layouts/filtered_list.html" %}
{% load static %}
{% load filter_utils %} {% load filter_utils %}
{% block page_title %}Parks{% endblock %} {% block page_title %}Parks{% endblock %}
@@ -6,25 +7,14 @@
{% block filter_errors %} {% block filter_errors %}
{% if filter.errors %} {% if filter.errors %}
<div class="bg-red-50 border-l-4 border-red-400 p-4 mb-4"> <div class="bg-red-50 border-l-4 border-red-400 p-4 mb-4">
<div class="flex"> <h3 class="text-sm font-medium text-red-800">Please correct the following errors:</h3>
<div class="flex-shrink-0"> <ul class="mt-2 text-sm text-red-700 list-disc pl-5 space-y-1">
<svg class="h-5 w-5 text-red-400" fill="currentColor" viewBox="0 0 20 20"> {% for field, errors in filter.errors.items %}
<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"/> {% for error in errors %}
</svg> <li>{{ field }}: {{ error }}</li>
</div> {% endfor %}
<div class="ml-3"> {% endfor %}
<h3 class="text-sm font-medium text-red-800">Please correct the following errors:</h3> </ul>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
{% for field, errors in filter.errors.items %}
{% for error in errors %}
<li>{{ field }}: {{ error }}</li>
{% endfor %}
{% endfor %}
</ul>
</div>
</div>
</div>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -44,12 +34,60 @@
Browse and filter amusement parks, theme parks, and water parks from around the world. Browse and filter amusement parks, theme parks, and water parks from around the world.
{% endblock %} {% 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 %} {% block no_results_message %}
<div class="text-center p-8 text-gray-500"> <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>. 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> </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 %} {% endblock %}

View File

@@ -1,35 +1,58 @@
{% if error %} {% if error %}
<div class="p-2 text-red-600"> <div class="text-red-600 bg-red-50 p-4 rounded-md" role="alert">
{{ error }} {{ error }}
</div> </div>
{% else %} {% else %}
{% for park in parks %} <div class="space-y-4">
<div class="p-2 hover:bg-gray-100 cursor-pointer"> {% for park in parks %}
<div class="flex items-center space-x-3"> <div class="bg-white shadow sm:rounded-lg">
{% if park.photos.exists %} <a href="{% url 'parks:park_detail' park.slug %}" class="block px-4 py-4 hover:bg-gray-50">
<img src="{{ park.photos.first.image.url }}" <div class="flex items-start space-x-4">
alt="{{ park.name }}" {% if park.photos.exists %}
class="w-8 h-8 object-cover rounded"> <img src="{{ park.photos.first.image.url }}"
{% else %} alt="{{ park.name }}"
<div class="w-8 h-8 bg-gray-200 rounded flex items-center justify-center"> class="h-20 w-20 object-cover rounded-lg">
<span class="text-xs text-gray-500">{{ park.name|first|upper }}</span> {% else %}
</div> <div class="h-20 w-20 bg-gray-100 rounded-lg flex items-center justify-center">
{% endif %} <span class="text-2xl font-medium text-gray-400">{{ park.name|first|upper }}</span>
<div> </div>
<div class="font-medium">{{ park.name }}</div> {% endif %}
{% with location=park.location.first %}
{% if location %} <div class="flex-1 min-w-0">
<div class="text-sm text-gray-500"> <div class="flex justify-between">
{{ location.city }}{% if location.state %}, {{ location.state }}{% endif %} <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 %}
<p class="mt-1 text-sm text-gray-500">
{{ location.city }}{% if location.state %}, {{ location.state }}{% endif %}
{% 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> </div>
{% endif %} </a>
{% endwith %}
</div>
</div> </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> </div>
{% empty %}
<div class="p-2 text-gray-500 text-center">
No parks found
</div>
{% endfor %}
{% endif %} {% endif %}

View File

@@ -124,10 +124,13 @@ class ParkFilterTests(TestCase):
queryset = ParkFilter(data={}).qs queryset = ParkFilter(data={}).qs
self.assertEqual(queryset.count(), 3) 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 queryset = ParkFilter(data={"status": "INVALID"}).qs
self.assertEqual(queryset.count(), 0) self.assertEqual(queryset.count(), 0)
self.assertEqual(queryset.count(), 3)
def test_date_range_filtering(self): def test_date_range_filtering(self):
"""Test date range filter functionality""" """Test date range filter functionality"""
@@ -199,11 +202,17 @@ class ParkFilterTests(TestCase):
self.assertEqual(queryset.count(), 1) self.assertEqual(queryset.count(), 1)
self.assertIn(self.park2, queryset) self.assertIn(self.park2, queryset)
# Test null owner (park3 has no owner) # Test parks without owner
queryset = ParkFilter(data={"owner": "null"}).qs queryset = ParkFilter(data={"has_owner": False}).qs
self.assertEqual(queryset.count(), 1) self.assertEqual(queryset.count(), 1)
self.assertIn(self.park3, queryset) 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) # Test empty filter (should return all)
queryset = ParkFilter(data={}).qs queryset = ParkFilter(data={}).qs
self.assertEqual(queryset.count(), 3) self.assertEqual(queryset.count(), 3)

View File

@@ -51,26 +51,43 @@ def get_park_areas(request: HttpRequest) -> HttpResponse:
def search_parks(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: try:
search_query = request.GET.get('search', '').strip()
if not search_query:
return HttpResponse('') # Return empty response for empty query
queryset = ( 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') .order_by('name')
) )
filter_params = {'search': request.GET.get('q', '').strip()}
park_filter = ParkFilter(filter_params, queryset=queryset) # Use our existing filter but with search-specific configuration
parks = park_filter.qs[:10] # Limit to 10 results 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, "parks": parks,
"is_quick_search": True "is_quick_search": True
}) })
response['HX-Trigger'] = 'searchComplete'
return response
except Exception as e: 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)}", "error": f"Error performing search: {str(e)}",
"is_quick_search": True "is_quick_search": True
}) })
response['HX-Trigger'] = 'searchError'
return response
def location_search(request: HttpRequest) -> JsonResponse: def location_search(request: HttpRequest) -> JsonResponse:
@@ -174,12 +191,14 @@ class ParkListView(HTMXFilterableMixin, ListView):
"photos", "photos",
"location", "location",
"rides", "rides",
"rides__manufacturer" "rides__manufacturer",
"areas"
) )
.annotate( .annotate(
total_rides=Count("rides"), total_rides=Count("rides"),
total_coasters=Count("rides", filter=Q(rides__category="RC")), total_coasters=Count("rides", filter=Q(rides__category="RC")),
) )
.order_by("name") # Ensure consistent ordering for pagination
) )
except Exception as e: except Exception as e:
messages.error(self.request, f"Error loading parks: {str(e)}") messages.error(self.request, f"Error loading parks: {str(e)}")