feat: Add detailed park and ride pages with HTMX integration

- Implemented park detail page with dynamic content loading for rides and weather.
- Created park list page with filters and search functionality.
- Developed ride detail page showcasing ride stats, reviews, and similar rides.
- Added ride list page with filtering options and dynamic loading.
- Introduced search results page with tabs for parks, rides, and users.
- Added HTMX tests for global search functionality.
This commit is contained in:
pacnpal
2025-12-19 19:53:20 -05:00
parent bf04e4d854
commit b9063ff4f8
154 changed files with 4536 additions and 2570 deletions

View File

@@ -0,0 +1,23 @@
from django.views.generic.edit import FormView
from django.http import JsonResponse, HttpResponse
class HTMXFormView(FormView):
"""Base FormView that supports field-level validation endpoints for HTMX.
Subclasses can call `validate_field` to return JSON errors for a single field.
"""
def validate_field(self, field_name):
"""Return JSON with errors for a single field based on the current form."""
form = self.get_form()
field = form[field_name]
form.is_valid() # populate errors
errors = form.errors.get(field_name, [])
return JsonResponse({"field": field_name, "errors": errors})
def post(self, request, *args, **kwargs):
# If HTMX field validation pattern: ?field=name
if request.headers.get("HX-Request") == "true" and request.GET.get("validate_field"):
return self.validate_field(request.GET.get("validate_field"))
return super().post(request, *args, **kwargs)

View File

@@ -0,0 +1,78 @@
from functools import wraps
from django.http import HttpResponse, JsonResponse
from django.template.loader import render_to_string
def htmx_partial(template_name):
"""Decorator for view functions to render partials for HTMX requests.
If the request is an HTMX request and a partial template exists with
the convention '<template_name>_partial.html', that template will be
rendered. Otherwise the provided template_name is used.
"""
def decorator(view_func):
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
resp = view_func(request, *args, **kwargs)
# If the view returned an HttpResponse, pass through
if isinstance(resp, HttpResponse):
return resp
# Expecting a tuple (context, template_name) or (context,)
context = {}
tpl = template_name
if isinstance(resp, tuple):
if len(resp) >= 1:
context = resp[0]
if len(resp) >= 2 and resp[1]:
tpl = resp[1]
# If HTMX, try partial template
if request.headers.get("HX-Request") == "true":
partial = tpl.replace(".html", "_partial.html")
try:
html = render_to_string(partial, context, request=request)
return HttpResponse(html)
except Exception:
# Fall back to full template
html = render_to_string(tpl, context, request=request)
return HttpResponse(html)
html = render_to_string(tpl, context, request=request)
return HttpResponse(html)
return _wrapped
return decorator
def htmx_redirect(url):
resp = HttpResponse("")
resp["HX-Redirect"] = url
return resp
def htmx_trigger(name: str, payload: dict = None):
resp = HttpResponse("")
if payload is None:
resp["HX-Trigger"] = name
else:
resp["HX-Trigger"] = JsonResponse({name: payload}).content.decode()
return resp
def htmx_refresh():
resp = HttpResponse("")
resp["HX-Refresh"] = "true"
return resp
def htmx_swap_oob(target_id: str, html: str):
"""Return an out-of-band swap response by wrapping HTML and setting headers.
Note: For simple use cases this returns an HttpResponse containing the
fragment; consumers should set `HX-Boost` headers when necessary.
"""
resp = HttpResponse(html)
resp["HX-Trigger"] = f"oob:{target_id}"
return resp

View File

@@ -0,0 +1,22 @@
import logging
from django.http import HttpResponseServerError
from django.template.loader import render_to_string
logger = logging.getLogger(__name__)
class HTMXErrorMiddleware:
"""Catch exceptions on HTMX requests and return formatted error partials."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
try:
return self.get_response(request)
except Exception as exc:
logger.exception("Error during request")
if request.headers.get("HX-Request") == "true":
html = render_to_string("htmx/components/error_message.html", {"title": "Server error", "message": "An unexpected error occurred."})
return HttpResponseServerError(html)
raise

View File

@@ -1,19 +1,86 @@
from typing import Optional
from django.views.generic.list import MultipleObjectMixin
from django.views.generic.edit import FormMixin
from django.template.loader import select_template
"""HTMX mixins for views. Single canonical definitions for partial rendering and triggers."""
class HTMXFilterableMixin(MultipleObjectMixin):
"""
A mixin that provides filtering capabilities for HTMX requests.
"""
"""Enhance list views to return partial templates for HTMX requests."""
filter_class = None
htmx_partial_suffix = "_partial.html"
def get_queryset(self):
queryset = super().get_queryset()
self.filterset = self.filter_class(self.request.GET, queryset=queryset)
return self.filterset.qs
qs = super().get_queryset()
if self.filter_class:
self.filterset = self.filter_class(self.request.GET, queryset=qs)
return self.filterset.qs
return qs
def get_template_names(self):
names = super().get_template_names()
if self.request.headers.get("HX-Request") == "true":
partials = [t.replace(".html", self.htmx_partial_suffix) for t in names]
try:
select_template(partials)
return partials
except Exception:
return names
return names
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["filter"] = self.filterset
return context
ctx = super().get_context_data(**kwargs)
if hasattr(self, "filterset"):
ctx["filter"] = self.filterset
return ctx
class HTMXFormMixin(FormMixin):
"""FormMixin that returns partials and field-level errors for HTMX requests."""
htmx_success_trigger: Optional[str] = None
def form_invalid(self, form):
if self.request.headers.get("HX-Request") == "true":
return self.render_to_response(self.get_context_data(form=form))
return super().form_invalid(form)
def form_valid(self, form):
res = super().form_valid(form)
if (
self.request.headers.get("HX-Request") == "true"
and self.htmx_success_trigger
):
res["HX-Trigger"] = self.htmx_success_trigger
return res
class HTMXInlineEditMixin(FormMixin):
"""Support simple inline edit flows: GET returns form partial, POST returns updated fragment."""
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)
class HTMXPaginationMixin:
"""Pagination helper that supports hx-trigger based infinite scroll or standard pagination."""
page_size = 20
def get_paginate_by(self, queryset):
return getattr(self, "paginate_by", self.page_size)
class HTMXModalMixin(HTMXFormMixin):
"""Mixin to help render forms inside modals and send close triggers on success."""
modal_close_trigger = "modal:close"
def form_valid(self, form):
res = super().form_valid(form)
if self.request.headers.get("HX-Request") == "true":
res["HX-Trigger"] = self.modal_close_trigger
return res

View File

@@ -0,0 +1,16 @@
from django.views.generic.edit import FormView
from django.shortcuts import get_object_or_404
class InlineEditView(FormView):
"""Generic inline edit view: GET returns form fragment, POST returns updated fragment."""
def get(self, request, *args, **kwargs):
return self.render_to_response(self.get_context_data())
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid():
self.object = form.save()
return self.render_to_response(self.get_context_data(object=self.object))
return self.form_invalid(form)

View File

@@ -0,0 +1,17 @@
from django.views.generic.edit import FormView
from django.http import HttpResponse
class HTMXModalFormView(FormView):
"""Render form inside a modal and respond with HTMX triggers on success."""
modal_template_name = "components/modals/modal_form.html"
def get_template_names(self):
return [self.modal_template_name]
def form_valid(self, form):
response = super().form_valid(form)
if self.request.headers.get("HX-Request") == "true":
response["HX-Trigger"] = "modal:close"
return response

View File

@@ -60,3 +60,32 @@ class SlugRedirectMixin(View):
if not self.object:
return {}
return {self.slug_url_kwarg: getattr(self.object, "slug", "")}
from django.views.generic import TemplateView
from django.shortcuts import render
class GlobalSearchView(TemplateView):
"""Unified search view with HTMX support for debounced results and suggestions."""
template_name = "core/search/search.html"
def get(self, request, *args, **kwargs):
q = request.GET.get("q", "")
results = []
suggestions = []
# Lightweight placeholder search: real implementation should query multiple models
if q:
# Return a small payload of mocked results to keep this scaffold safe
results = [{"title": f"Result for {q}", "url": "#", "subtitle": "Park"}]
suggestions = [{"text": q, "url": "#"}]
context = {"results": results, "suggestions": suggestions}
# If HTMX request, render dropdown partial
if request.headers.get("HX-Request") == "true":
return render(request, "core/search/partials/search_dropdown.html", context)
return render(request, self.template_name, context)