mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-29 13:27:00 -05:00
Compare commits
3 Commits
feature/dj
...
e3901587b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3901587b7 | ||
|
|
401449201c | ||
|
|
1ca1362fee |
71
memory-bank/decisions/park-search-improvements.md
Normal file
71
memory-bank/decisions/park-search-improvements.md
Normal 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
|
||||||
24
memory-bank/decisions/search-form-fix.md
Normal file
24
memory-bank/decisions/search-form-fix.md
Normal 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
|
||||||
@@ -1,105 +1,130 @@
|
|||||||
# 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 using built-in HTMX and Alpine.js
|
||||||
class ParkSearchForm(forms.Form):
|
```html
|
||||||
park = forms.ModelChoiceField(
|
<div x-data="{ query: '', selectedId: null }"
|
||||||
queryset=Park.objects.all(),
|
@search-selected.window="...">
|
||||||
required=False,
|
<form hx-get="..." hx-trigger="input changed delay:300ms">
|
||||||
widget=AutocompleteWidget(
|
<!-- Search input and UI components -->
|
||||||
ac_class=ParkAutocomplete,
|
</form>
|
||||||
attrs={
|
</div>
|
||||||
'class': 'w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white',
|
|
||||||
'placeholder': 'Search parks...'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
- No custom JavaScript required
|
||||||
### Autocomplete
|
- Uses native frameworks' features for:
|
||||||
```python
|
- State management (Alpine.js)
|
||||||
class ParkAutocomplete(BaseAutocomplete):
|
- AJAX requests (HTMX)
|
||||||
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'))
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
1. **Security**
|
|
||||||
- Tiered access control:
|
|
||||||
* Public basic search
|
|
||||||
* Authenticated users get autocomplete
|
|
||||||
* Protected endpoints via settings
|
|
||||||
- CSRF protection
|
|
||||||
- Input validation
|
|
||||||
|
|
||||||
2. **Real-time Search**
|
|
||||||
- Debounced input handling
|
|
||||||
- Instant results display
|
|
||||||
- Loading indicators
|
- Loading indicators
|
||||||
|
- Keyboard interactions
|
||||||
|
|
||||||
3. **Accessibility**
|
### 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
|
- ARIA labels and roles
|
||||||
- Keyboard navigation support
|
- Keyboard navigation
|
||||||
- Screen reader compatibility
|
- Proper focus management
|
||||||
|
- Screen reader support
|
||||||
|
|
||||||
4. **Integration**
|
## API Response Format
|
||||||
- Works with existing filter system
|
|
||||||
- Maintains view mode selection
|
|
||||||
- Preserves URL state
|
|
||||||
|
|
||||||
## Performance Considerations
|
### Suggestions Endpoint (`/parks/suggest_parks/`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "string",
|
||||||
|
"name": "string",
|
||||||
|
"status": "string",
|
||||||
|
"location": "string",
|
||||||
|
"url": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
- Prefetch related owner data
|
### Field Details
|
||||||
- Uses base queryset optimizations
|
- `id`: Database ID (string format)
|
||||||
- Debounced search requests
|
- `name`: Park name
|
||||||
- Proper index usage on name field
|
- `status`: Formatted status display (e.g., "Operating")
|
||||||
|
- `location`: Formatted location string
|
||||||
|
- `url`: Full detail page URL
|
||||||
|
|
||||||
## Future Improvements
|
## Test Coverage
|
||||||
|
|
||||||
- Consider adding full-text search
|
### API Tests
|
||||||
- Implement result caching
|
- JSON format validation
|
||||||
- Add geographic search capabilities
|
- Empty search handling
|
||||||
- Enhance filter integration
|
- Field type checking
|
||||||
|
- Result limit verification
|
||||||
|
- Response structure
|
||||||
|
|
||||||
|
### UI Integration Tests
|
||||||
|
- View mode persistence
|
||||||
|
- Loading state verification
|
||||||
|
- Error handling
|
||||||
|
- Keyboard interaction
|
||||||
|
|
||||||
|
### Data Format Tests
|
||||||
|
- Location string formatting
|
||||||
|
- Status display formatting
|
||||||
|
- URL generation
|
||||||
|
- Field type validation
|
||||||
|
|
||||||
|
### Performance Tests
|
||||||
|
- Debounce functionality
|
||||||
|
- Result limiting (8 items)
|
||||||
|
- Query optimization
|
||||||
|
- Response timing
|
||||||
@@ -47,38 +47,64 @@
|
|||||||
{% 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"
|
||||||
<form hx-get="{% url 'parks:park_list' %}"
|
x-data="{ query: '', selectedId: null }"
|
||||||
hx-target="#park-results"
|
@search-selected.window="
|
||||||
hx-push-url="true"
|
query = $event.detail;
|
||||||
hx-trigger="change from:.park-search">
|
selectedId = $event.target.value;
|
||||||
{% csrf_token %}
|
$refs.filterForm.querySelector('input[name=search]').value = query;
|
||||||
{{ search_form.park }}
|
$refs.filterForm.submit();
|
||||||
</form>
|
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 -->
|
<!-- 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-1/2 -translate-y-1/2"
|
||||||
role="status"
|
role="status"
|
||||||
aria-label="Loading search results">
|
aria-label="Loading search results">
|
||||||
<svg class="h-5 w-5 text-gray-400 animate-spin" viewBox="0 0 24 24" aria-hidden="true">
|
<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"/>
|
<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"/>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="sr-only">Searching...</span>
|
<span class="sr-only">Searching...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white shadow sm:rounded-lg">
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<h3 class="text-lg font-medium leading-6 text-gray-900">Filters</h3>
|
<h3 class="text-lg font-medium leading-6 text-gray-900">Filters</h3>
|
||||||
<form id="filter-form"
|
<form id="filter-form"
|
||||||
|
x-ref="filterForm"
|
||||||
hx-get="{% url 'parks:park_list' %}"
|
hx-get="{% url 'parks:park_list' %}"
|
||||||
hx-target="#park-results"
|
hx-target="#park-results"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-trigger="change"
|
hx-trigger="change, submit"
|
||||||
class="mt-4">
|
class="mt-4">
|
||||||
|
<input type="hidden" name="search" value="{{ request.GET.search }}">
|
||||||
{% include "search/components/filter_form.html" with filter=filter %}
|
{% include "search/components/filter_form.html" with filter=filter %}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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,70 @@ uv run pytest --cov=parks parks/tests/
|
|||||||
|
|
||||||
## Test Coverage
|
## Test Coverage
|
||||||
|
|
||||||
### Unit Tests
|
### Search API Tests
|
||||||
- `test_autocomplete_results`: Validates search result filtering
|
- `test_search_json_format`: Validates API response structure
|
||||||
- `test_search_form_valid`: Ensures form validation works
|
- `test_empty_search_json`: Tests empty search handling
|
||||||
- `test_autocomplete_class`: Checks autocomplete configuration
|
- `test_search_format_validation`: Verifies all required fields and types
|
||||||
- `test_search_with_filters`: Verifies filter integration
|
- `test_suggestion_limit`: Confirms 8-item result limit
|
||||||
|
|
||||||
### Integration Tests
|
### Search Functionality Tests
|
||||||
- `test_empty_search`: Tests default behavior
|
- `test_autocomplete_results`: Validates real-time suggestion filtering
|
||||||
- `test_partial_match_search`: Validates partial text matching
|
- `test_search_with_filters`: Tests filter integration with search
|
||||||
- `test_htmx_request_handling`: Ensures HTMX compatibility
|
- `test_partial_match_search`: Verifies partial text matching works
|
||||||
- `test_view_mode_persistence`: Checks view state management
|
|
||||||
- `test_unauthenticated_access`: Verifies authentication requirements
|
|
||||||
|
|
||||||
### Security Tests
|
### UI Integration Tests
|
||||||
Parks search implements a tiered access approach:
|
- `test_view_mode_persistence`: Ensures view mode is maintained
|
||||||
- Basic search is public
|
- `test_empty_search`: Tests default state behavior
|
||||||
- Autocomplete requires authentication
|
- `test_htmx_request_handling`: Validates HTMX interactions
|
||||||
- Configuration set in settings.py: `AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = True`
|
|
||||||
|
### 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
|
## 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,80 @@ 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_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']
|
||||||
|
|||||||
@@ -33,11 +33,22 @@ class ParkSearchView(TemplateView):
|
|||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
def suggest_parks(request: HttpRequest) -> HttpResponse:
|
def suggest_parks(request: HttpRequest) -> JsonResponse:
|
||||||
"""Legacy endpoint for old search UI - redirects to autocomplete."""
|
"""Return park search suggestions as JSON."""
|
||||||
query = request.GET.get('search', '').strip()
|
query = request.GET.get('search', '').strip()
|
||||||
if query:
|
if not query:
|
||||||
return JsonResponse({
|
return JsonResponse({'results': []})
|
||||||
'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
|
||||||
|
|
||||||
|
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})
|
||||||
42
static/js/search.js
Normal file
42
static/js/search.js
Normal 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'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -226,3 +226,4 @@ TAILWIND_CLI_DIST_CSS = os.path.join(BASE_DIR, "static/css/tailwind.css")
|
|||||||
TURNSTILE_SITE_KEY = "0x4AAAAAAAyqVp3RjccrC9Kz"
|
TURNSTILE_SITE_KEY = "0x4AAAAAAAyqVp3RjccrC9Kz"
|
||||||
TURNSTILE_SECRET_KEY = "0x4AAAAAAAyqVrQolYsrAFGJ39PXHJ_HQzY"
|
TURNSTILE_SECRET_KEY = "0x4AAAAAAAyqVrQolYsrAFGJ39PXHJ_HQzY"
|
||||||
TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
|||||||
Reference in New Issue
Block a user