From c197051b251c6cb54aeaf9c95ba6fb099bbc30e3 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Thu, 13 Feb 2025 09:42:58 -0500 Subject: [PATCH] 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 --- .../features/search/testing-implementation.md | 48 +++++--- parks/filters.py | 109 +++++++++--------- parks/static/parks/css/search.css | 32 +++++ parks/static/parks/js/search.js | 28 +++++ parks/templates/parks/park_list.html | 80 +++++++++---- .../parks/partials/park_search_results.html | 77 ++++++++----- parks/tests/test_filters.py | 17 ++- parks/views.py | 35 ++++-- 8 files changed, 292 insertions(+), 134 deletions(-) create mode 100644 parks/static/parks/css/search.css create mode 100644 parks/static/parks/js/search.js diff --git a/memory-bank/features/search/testing-implementation.md b/memory-bank/features/search/testing-implementation.md index 81d566d7..0df47b97 100644 --- a/memory-bank/features/search/testing-implementation.md +++ b/memory-bank/features/search/testing-implementation.md @@ -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 diff --git a/parks/filters.py b/parks/filters.py index a3b33aa0..a172fe44 100644 --- a/parks/filters.py +++ b/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' ) - - 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() - + has_owner = BooleanFilter(method='filter_has_owner') + + # 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 -) \ No newline at end of file + 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 \ No newline at end of file diff --git a/parks/static/parks/css/search.css b/parks/static/parks/css/search.css new file mode 100644 index 00000000..250a8745 --- /dev/null +++ b/parks/static/parks/css/search.css @@ -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; + } +} \ No newline at end of file diff --git a/parks/static/parks/js/search.js b/parks/static/parks/js/search.js new file mode 100644 index 00000000..f022966e --- /dev/null +++ b/parks/static/parks/js/search.js @@ -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 = ''; + }); +}); \ No newline at end of file diff --git a/parks/templates/parks/park_list.html b/parks/templates/parks/park_list.html index 1ae42a47..1606969a 100644 --- a/parks/templates/parks/park_list.html +++ b/parks/templates/parks/park_list.html @@ -1,4 +1,5 @@ {% extends "search/layouts/filtered_list.html" %} +{% load static %} {% load filter_utils %} {% block page_title %}Parks{% endblock %} @@ -6,25 +7,14 @@ {% block filter_errors %} {% if filter.errors %}
No parks found matching your search.
+