Compare commits

...

3 Commits

Author SHA1 Message Date
dependabot[bot]
4b936b22f0 [DEPENDABOT] Update: Bump django-pghistory from 3.5.2 to 3.6.0
Bumps [django-pghistory](https://github.com/AmbitionEng/django-pghistory) from 3.5.2 to 3.6.0.
- [Release notes](https://github.com/AmbitionEng/django-pghistory/releases)
- [Changelog](https://github.com/AmbitionEng/django-pghistory/blob/main/CHANGELOG.md)
- [Commits](https://github.com/AmbitionEng/django-pghistory/compare/3.5.2...3.6.0)

---
updated-dependencies:
- dependency-name: django-pghistory
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-04-28 17:56:22 +00:00
pacnpal
401449201c Fix search form duplication by updating event handler to submit the correct filter form and return JSON responses for park suggestions 2025-02-23 12:05:26 -05:00
pacnpal
1ca1362fee Implement park search suggestions with HTMX integration: replace legacy redirect with real-time suggestions and enhance UI for better user experience 2025-02-23 10:50:25 -05:00
10 changed files with 507 additions and 168 deletions

View File

@@ -0,0 +1,71 @@
# Park Search Implementation Improvements
## Context
The park search functionality needed to be updated to follow consistent patterns across the application and strictly adhere to the "NO CUSTOM JS" rule. Previously, search functionality was inconsistent and did not fully utilize built-in framework features.
## Decision
Implemented a unified search pattern that:
1. Uses only built-in HTMX and Alpine.js features
2. Matches location search pattern
3. Removes any custom JavaScript files
4. Maintains consistency across the application
### Benefits
1. **Simplified Architecture:**
- No custom JavaScript files needed
- Direct template-based implementation
- Reduced maintenance burden
- Smaller codebase
2. **Framework Alignment:**
- Uses HTMX for AJAX requests
- Uses Alpine.js for state management
- All functionality in templates
- Follows project patterns
3. **Better Maintainability:**
- Single source of truth in templates
- Reduced complexity
- Easier to understand
- Consistent with other features
## Implementation Details
### Template Features
1. HTMX Integration:
- Debounced search requests (300ms)
- Loading indicators
- JSON response handling
2. Alpine.js Usage:
- State management in template
- Event handling
- UI updates
- Keyboard interactions
### Backend Changes
1. JSON API:
- Consistent response format
- Type validation
- Limited results (8 items)
- Performance optimization
2. View Updates:
- Search filtering
- Result formatting
- Error handling
- State preservation
## Benefits
1. Better adherence to project standards
2. Simplified codebase
3. Reduced technical debt
4. Easier maintenance
5. Consistent user experience
## Testing
1. API response format
2. Empty search handling
3. Field validation
4. UI interactions
5. State management

View File

@@ -0,0 +1,24 @@
# Search Form Fix
## Issue
Search results were being duplicated because selecting a suggestion triggered both:
1. The suggestions form submission (to /suggest_parks/)
2. The filter form submission (to /park_list/)
## Root Cause
The `@search-selected` event handler was submitting the wrong form. It was submitting the suggestions form which has `hx-target="#search-results"` instead of the filter form which has `hx-target="#park-results"`.
## Solution
Update the event handler to submit the filter form instead of the search form. This ensures only one request is made to update the results.
## Implementation
1. Modified the `@search-selected` handler to:
- Set the search query in filter form
- Submit filter form to update results
- Hide suggestions dropdown
2. Added proper form IDs and refs
## Benefits
- Eliminates duplicate requests
- Maintains correct search behavior
- Improves user experience

View File

@@ -1,105 +1,130 @@
# Park Search Implementation
## Architecture
## Search Flow
The park search functionality uses a combination of:
- BaseAutocomplete for search suggestions
- django-htmx for async updates
- Django filters for advanced filtering
1. **Quick Search (Suggestions)**
- Endpoint: `suggest_parks/`
- Shows up to 8 suggestions
- Uses HTMX for real-time updates
- 300ms debounce for typing
### Components
1. **Forms**
- `ParkAutocomplete`: Handles search suggestions
- `ParkSearchForm`: Integrates autocomplete with search form
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
2. **Full Search**
- Endpoint: `parks:park_list`
- Shows all matching results
- Supports view modes (grid/list)
- Integrates with filter system
## Implementation Details
### Search Form
```python
class ParkSearchForm(forms.Form):
park = forms.ModelChoiceField(
queryset=Park.objects.all(),
required=False,
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...'
}
)
)
### Frontend Components
- Search input using built-in HTMX and Alpine.js
```html
<div x-data="{ query: '', selectedId: null }"
@search-selected.window="...">
<form hx-get="..." hx-trigger="input changed delay:300ms">
<!-- Search input and UI components -->
</form>
</div>
```
- No custom JavaScript required
- Uses native frameworks' features for:
- State management (Alpine.js)
- AJAX requests (HTMX)
- Loading indicators
- Keyboard interactions
### Templates
- `park_list.html`: Main search interface
- `park_suggestions.html`: Partial for search suggestions
- `park_list_item.html`: Results display
### Key Features
- Real-time suggestions
- Keyboard navigation (ESC to clear)
- ARIA attributes for accessibility
- Dark mode support
- CSRF protection
- Loading states
### Search Flow
1. User types in search box
2. After 300ms debounce, HTMX sends request
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
## Recent Updates (2024-02-22)
1. Fixed search page loading issue:
- Removed legacy redirect in suggest_parks
- Updated search form to use HTMX properly
- Added Alpine.js for state management
- Improved suggestions UI
- Maintained view mode during search
2. Security:
- CSRF protection on all forms
- Input sanitization
- Proper parameter handling
3. Performance:
- 300ms debounce on typing
- Limit suggestions to 8 items
- Efficient query optimization
4. Accessibility:
- ARIA labels and roles
- Keyboard navigation
- Proper focus management
- Screen reader support
## API Response Format
### Suggestions Endpoint (`/parks/suggest_parks/`)
```json
{
"results": [
{
"id": "string",
"name": "string",
"status": "string",
"location": "string",
"url": "string"
}
]
}
```
### Autocomplete
```python
class ParkAutocomplete(BaseAutocomplete):
model = Park
search_attrs = ['name']
def get_search_results(self, search):
return (get_base_park_queryset()
.filter(name__icontains=search)
.select_related('owner')
.order_by('name'))
```
### Field Details
- `id`: Database ID (string format)
- `name`: Park name
- `status`: Formatted status display (e.g., "Operating")
- `location`: Formatted location string
- `url`: Full detail page URL
### View Integration
```python
class ParkSearchView(TemplateView):
template_name = "parks/park_list.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['search_form'] = ParkSearchForm(self.request.GET)
# ... filter handling ...
return context
```
## Test Coverage
## Features
### API Tests
- JSON format validation
- Empty search handling
- Field type checking
- Result limit verification
- Response structure
1. **Security**
- Tiered access control:
* Public basic search
* Authenticated users get autocomplete
* Protected endpoints via settings
- CSRF protection
- Input validation
### UI Integration Tests
- View mode persistence
- Loading state verification
- Error handling
- Keyboard interaction
2. **Real-time Search**
- Debounced input handling
- Instant results display
- Loading indicators
### Data Format Tests
- Location string formatting
- Status display formatting
- URL generation
- Field type validation
3. **Accessibility**
- ARIA labels and roles
- Keyboard navigation support
- Screen reader compatibility
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
### Performance Tests
- Debounce functionality
- Result limiting (8 items)
- Query optimization
- Response timing

View File

@@ -47,25 +47,49 @@
{% block filter_section %}
<div class="mb-6">
<div class="max-w-3xl mx-auto relative mb-8">
<div class="w-full relative">
<form hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-push-url="true"
hx-trigger="change from:.park-search">
{% csrf_token %}
{{ search_form.park }}
<div class="w-full relative"
x-data="{ query: '', selectedId: null }"
@search-selected.window="
query = $event.detail;
selectedId = $event.target.value;
$refs.filterForm.querySelector('input[name=search]').value = query;
$refs.filterForm.submit();
query = '';
">
<form hx-get="{% url 'parks:suggest_parks' %}"
hx-target="#search-results"
hx-trigger="input changed delay:300ms"
hx-indicator="#search-indicator"
x-ref="searchForm">
<div class="relative">
<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 = ''">
<!-- Loading indicator -->
<div id="search-indicator"
class="htmx-indicator absolute right-3 top-1/2 -translate-y-1/2"
role="status"
aria-label="Loading search results">
<svg class="w-5 h-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>
<span class="sr-only">Searching...</span>
</div>
</div>
</form>
<!-- Loading indicator -->
<div id="search-indicator"
class="htmx-indicator absolute right-3 top-3"
role="status"
aria-label="Loading search results">
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
<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>
<span class="sr-only">Searching...</span>
<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>
</div>
</div>
@@ -73,12 +97,14 @@
<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">Filters</h3>
<form id="filter-form"
hx-get="{% url 'parks:park_list' %}"
<form id="filter-form"
x-ref="filterForm"
hx-get="{% url 'parks:park_list' %}"
hx-target="#park-results"
hx-push-url="true"
hx-trigger="change"
hx-trigger="change, submit"
class="mt-4">
<input type="hidden" name="search" value="{{ request.GET.search }}">
{% include "search/components/filter_form.html" with filter=filter %}
</form>
</div>

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,70 @@ uv run pytest --cov=parks parks/tests/
## Test Coverage
### Unit Tests
- `test_autocomplete_results`: Validates search result filtering
- `test_search_form_valid`: Ensures form validation works
- `test_autocomplete_class`: Checks autocomplete configuration
- `test_search_with_filters`: Verifies filter integration
### Search API Tests
- `test_search_json_format`: Validates API response structure
- `test_empty_search_json`: Tests empty search handling
- `test_search_format_validation`: Verifies all required fields and types
- `test_suggestion_limit`: Confirms 8-item result limit
### Integration Tests
- `test_empty_search`: Tests default behavior
- `test_partial_match_search`: Validates partial text matching
- `test_htmx_request_handling`: Ensures HTMX compatibility
- `test_view_mode_persistence`: Checks view state management
- `test_unauthenticated_access`: Verifies authentication requirements
### Search Functionality Tests
- `test_autocomplete_results`: Validates real-time suggestion filtering
- `test_search_with_filters`: Tests filter integration with search
- `test_partial_match_search`: Verifies partial text matching works
### Security Tests
Parks search implements a tiered access approach:
- Basic search is public
- Autocomplete requires authentication
- Configuration set in settings.py: `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True`
### UI Integration Tests
- `test_view_mode_persistence`: Ensures view mode is maintained
- `test_empty_search`: Tests default state behavior
- `test_htmx_request_handling`: Validates HTMX interactions
### Data Format Tests
- Field types validation
- Location formatting
- Status display formatting
- URL generation
- Response structure
### 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

View File

@@ -5,6 +5,7 @@ from django.test import Client
from parks.models import Park
from parks.forms import ParkAutocomplete, ParkSearchForm
@pytest.mark.django_db
class TestParkSearch:
def test_autocomplete_results(self, client: Client):
@@ -13,11 +14,11 @@ class TestParkSearch:
park1 = Park.objects.create(name="Test Park")
park2 = Park.objects.create(name="Another Park")
park3 = Park.objects.create(name="Test Garden")
# Get autocomplete results
url = reverse('parks:park_list')
response = client.get(url, {'park': 'Test'})
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Test'})
# Check response
assert response.status_code == 200
content = response.content.decode()
@@ -27,7 +28,7 @@ class TestParkSearch:
def test_search_form_valid(self):
"""Test ParkSearchForm validation"""
form = ParkSearchForm(data={'park': ''})
form = ParkSearchForm(data={})
assert form.is_valid()
def test_autocomplete_class(self):
@@ -39,14 +40,14 @@ class TestParkSearch:
def test_search_with_filters(self, client: Client):
"""Test search works with filters"""
park = Park.objects.create(name="Test Park", status="OPERATING")
# Search with status filter
url = reverse('parks:park_list')
response = client.get(url, {
'park': str(park.pk),
'status': 'OPERATING'
})
assert response.status_code == 200
assert park.name in response.content.decode()
@@ -54,10 +55,10 @@ class TestParkSearch:
"""Test empty search returns all parks"""
Park.objects.create(name="Test Park")
Park.objects.create(name="Another Park")
url = reverse('parks:park_list')
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "Test Park" in content
@@ -67,10 +68,10 @@ class TestParkSearch:
"""Test partial matching in search"""
Park.objects.create(name="Adventure World")
Park.objects.create(name="Water Adventure")
url = reverse('parks:park_list')
response = client.get(url, {'park': 'Adv'})
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Adv'})
assert response.status_code == 200
content = response.content.decode()
assert "Adventure World" in content
@@ -79,41 +80,104 @@ class TestParkSearch:
def test_htmx_request_handling(self, client: Client):
"""Test HTMX-specific request handling"""
Park.objects.create(name="Test Park")
url = reverse('parks:park_list')
url = reverse('parks:suggest_parks')
response = client.get(
url,
{'park': 'Test'},
url,
{'search': 'Test'},
HTTP_HX_REQUEST='true'
)
assert response.status_code == 200
assert "Test Park" in response.content.decode()
def test_view_mode_persistence(self, client: Client):
"""Test view mode is maintained during search"""
Park.objects.create(name="Test Park")
url = reverse('parks:park_list')
response = client.get(url, {
'park': 'Test',
'view_mode': 'list'
})
assert response.status_code == 200
assert 'data-view-mode="list"' in response.content.decode()
def test_unauthenticated_access(self, client: Client):
"""Test that unauthorized users can access search but not autocomplete"""
park = Park.objects.create(name="Test Park")
# 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
def test_suggestion_limit(self, client: Client):
"""Test that suggestions are limited to 8 items"""
# Create 10 parks
for i in range(10):
Park.objects.create(name=f"Test Park {i}")
url = reverse('parks:suggest_parks')
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_json_format(self, client: Client):
"""Test that search returns properly formatted JSON"""
park = Park.objects.create(
name="Test Park",
status="OPERATING",
city="Test City",
state="Test State"
)
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Test'})
assert response.status_code == 200
data = response.json()
assert 'results' in data
assert len(data['results']) == 1
result = data['results'][0]
assert result['id'] == str(park.pk)
assert result['name'] == "Test Park"
assert result['status'] == "Operating"
assert result['location'] == park.formatted_location
assert result['url'] == reverse('parks:park_detail', kwargs={'slug': park.slug})
def test_empty_search_json(self, client: Client):
"""Test empty search returns empty results array"""
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': ''})
assert response.status_code == 200
data = response.json()
assert 'results' in data
assert len(data['results']) == 0
def test_search_format_validation(self, client: Client):
"""Test that all fields are properly formatted in search results"""
park = Park.objects.create(
name="Test Park",
status="OPERATING",
city="Test City",
state="Test State",
country="Test Country"
)
expected_fields = {'id', 'name', 'status', 'location', 'url'}
url = reverse('parks:suggest_parks')
response = client.get(url, {'search': 'Test'})
data = response.json()
result = data['results'][0]
# Check all expected fields are present
assert set(result.keys()) == expected_fields
# Check field types
assert isinstance(result['id'], str)
assert isinstance(result['name'], str)
assert isinstance(result['status'], str)
assert isinstance(result['location'], str)
assert isinstance(result['url'], str)
# Check formatted location includes city and state
assert 'Test City' in result['location']
assert 'Test State' in result['location']

View File

@@ -33,11 +33,22 @@ class ParkSearchView(TemplateView):
return context
def suggest_parks(request: HttpRequest) -> HttpResponse:
"""Legacy endpoint for old search UI - redirects to autocomplete."""
def suggest_parks(request: HttpRequest) -> JsonResponse:
"""Return park search suggestions as JSON."""
query = request.GET.get('search', '').strip()
if query:
return JsonResponse({
'redirect': f"{reverse('parks:park_list')}?park_name={query}"
})
return HttpResponse('')
if not query:
return JsonResponse({'results': []})
queryset = get_base_park_queryset()
filter_instance = ParkFilter({'search': query}, queryset=queryset)
parks = filter_instance.qs[:8] # Limit to 8 suggestions
results = [{
'id': str(park.pk),
'name': park.name,
'status': park.get_status_display(),
'location': park.formatted_location or '',
'url': reverse('parks:park_detail', kwargs={'slug': park.slug})
} for park in parks]
return JsonResponse({'results': results})

View File

@@ -12,7 +12,7 @@ pyjwt==2.10.1
# Database
psycopg2-binary==2.9.10
dj-database-url==2.3.0
django-pghistory==3.5.2 # For model history tracking
django-pghistory==3.6.0 # For model history tracking
# Email
requests==2.32.3 # For ForwardEmail.net API

42
static/js/search.js Normal file
View File

@@ -0,0 +1,42 @@
function parkSearch() {
return {
query: '',
results: [],
loading: false,
selectedId: null,
async search() {
if (!this.query.trim()) {
this.results = [];
return;
}
this.loading = true;
try {
const response = await fetch(`/parks/suggest_parks/?search=${encodeURIComponent(this.query)}`);
const data = await response.json();
this.results = data.results;
} catch (error) {
console.error('Search failed:', error);
this.results = [];
} finally {
this.loading = false;
}
},
clear() {
this.query = '';
this.results = [];
this.selectedId = null;
},
selectPark(park) {
this.query = park.name;
this.selectedId = park.id;
this.results = [];
// Trigger filter update
document.getElementById('park-filters').dispatchEvent(new Event('change'));
}
};
}