mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-21 20:11:10 -05:00
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:
23
backend/apps/core/forms/htmx_forms.py
Normal file
23
backend/apps/core/forms/htmx_forms.py
Normal 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)
|
||||
78
backend/apps/core/htmx_utils.py
Normal file
78
backend/apps/core/htmx_utils.py
Normal 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
|
||||
22
backend/apps/core/middleware/htmx_error_middleware.py
Normal file
22
backend/apps/core/middleware/htmx_error_middleware.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
16
backend/apps/core/views/inline_edit.py
Normal file
16
backend/apps/core/views/inline_edit.py
Normal 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)
|
||||
17
backend/apps/core/views/modal_views.py
Normal file
17
backend/apps/core/views/modal_views.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user