Enhance MemberListView with HTMX integration, refactor member filter template for partial rendering, and add HTMX utility mixins.

This commit is contained in:
2026-01-10 23:57:06 +01:00
parent f4c5377727
commit d2d50afdd7
4 changed files with 258 additions and 162 deletions

View File

@@ -10,12 +10,13 @@ from rules.contrib.views import PermissionRequiredMixin
from members.filters import MemberFilter from members.filters import MemberFilter
from members.models import Member from members.models import Member
from ..mixins import HTMXViewMixin
class MemberListView(PermissionRequiredMixin, FilterView): class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView):
filterset_class = MemberFilter filterset_class = MemberFilter
paginate_by = 50 paginate_by = 50
permission_denied_message = _("You do not have permission to view this page.") permission_denied_message = _("You do not have permission to view this page.")
permission_required = "members.view_member" permission_required = "members.view_member"
partial_name = "members/member_filter.html#content"
def handle_no_permission(self) -> HttpResponseRedirect: def handle_no_permission(self) -> HttpResponseRedirect:
messages.error(self.request, self.get_permission_denied_message()) messages.error(self.request, self.get_permission_denied_message())

93
backend/mixins.py Normal file
View File

@@ -0,0 +1,93 @@
from django.http import HttpResponse
from django.template.loader import render_to_string
from django_htmx.http import HttpResponseClientRedirect, HttpResponseClientRefresh
class HTMXPartialMixin:
"""Mixin that automatically switches to a partial template when the request is made via HTMX."""
partial_template_name = None
def get_template_names(self):
# If HTMX request and a partial is defined, return it
if getattr(self.request, "htmx", False) and self.partial_template_name:
return [self.partial_template_name]
return super().get_template_names()
class HTMXViewMixin:
"""
A full featured HTMX integration mixin for Django CBVs.
Supports:
- partial rendering via django-partials
- HX-Redirect
- HX-Push-URL
- HX-Trigger events
- HX-Refresh
- Graceful fallback to normal DJango rendering
"""
# Name of the partial block: template.html#partial_name
partial_name = None
# Optional: automatically push URL on GET
htmx_push_url = None
# Optional: trigger events after rendering
htmx_trigger = None
htmx_trigger_after_settle = None
htmx_trigger_after_swap = None
# Optional: redirect target for HTMX
htmx_redirect_url = None
# Optional: refresh the page
htmx_refresh = False
def render_partial(self, context):
"""Render a django-partials block."""
request = self.request
return render_to_string(self.partial_name, context, request=request)
def apply_htmx_headers(self, response):
"""Attach HX-* headers to the response."""
request = self.request
if request.htmx:
is_get = request.method == "GET"
is_pagination = "page" in request.GET
if is_get and not is_pagination:
# Push the current path unless overridden
response.headers["HX-Push-Url"] = self.htmx_push_url or request.get_full_path()
print(response.headers)
if self.htmx_trigger:
response.headers["HX-Trigger"] = self.htmx_trigger
if self.htmx_trigger_after_settle:
response.headers["HX-Trigger-After-Settle"] = self.htmx_trigger_after_settle
if self.htmx_trigger_after_swap:
response.headers["HX-Trigger-After-Swap"] = self.htmx_trigger_after_swap
return response
def render_to_response(self, context, **response_kwargs):
"""Renders HTMX response, applying headers and handling directives"""
request = self.request
if not request.htmx:
response = super().render_to_response(context, **response_kwargs)
return self.apply_htmx_headers(response)
if self.htmx_redirect_url:
return HttpResponseClientRedirect(self.htmx_redirect_url)
if self.htmx_refresh:
return HttpResponseClientRefresh()
html = self.render_partial(context)
response = HttpResponse(html, **response_kwargs)
return self.apply_htmx_headers(response)

View File

@@ -101,7 +101,7 @@
<!-- MAIN CONTENT--> <!-- MAIN CONTENT-->
<div class="drawer-content flex w-full"> <div class="drawer-content flex w-full">
<main class="bg-base-100 border border-base-300 rounded-xl m-4 ml-2 p-6 w-full"> <main class="bg-base-100 border border-base-300 rounded-xl m-4 ml-2 p-6 w-full" id="content">
{% block content %} {% block content %}
<h1 class="text-3xl font-bold">Welcome!</h1> <h1 class="text-3xl font-bold">Welcome!</h1>
<p>This is your main content area.</p> <p>This is your main content area.</p>

View File

@@ -6,13 +6,14 @@
{% load pagination %} {% load pagination %}
{% block content %} {% block content %}
{% partialdef content inline %}
<h1 class="page-title">Members</h1> <h1 class="page-title">Members</h1>
<div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border"> <div class="lg:hidden collapse collapse-plus bg-base-100 border-neutral border">
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title text-sm font-semibold"><i class="fa-solid fa-filter mr-2"></i>{% translate "Filter" %}{% if filter.is_bound %}<span class="ml-2 badge badge-sm badge-neutral">active</span>{% endif %}</div> <div class="collapse-title text-sm font-semibold"><i class="fa-solid fa-filter mr-2"></i>{% translate "Filter" %}{% if filter.is_bound %}<span class="ml-2 badge badge-sm badge-neutral">active</span>{% endif %}</div>
<div class="collapse-content"> <div class="collapse-content">
<form class="flex flex-col gap-2"> <form class="flex flex-col gap-2" hx-get="{% url "backend:members:list" %}" hx-target="#content">
{% for field in filter.form %} {% for field in filter.form %}
{% form_field field show_label=False size="small" %} {% form_field field show_label=False size="small" %}
{% endfor %} {% endfor %}
@@ -23,7 +24,7 @@
</button> </button>
{% if filter.is_bound %} {% if filter.is_bound %}
<a class="btn btn-outline btn-error btn-sm grow" href="{% url "backend:members:list" %}"> <a class="btn btn-outline btn-error btn-sm grow" href="{% url "backend:members:list" %}" hx-get="{% url "backend:members:list" %}" hx-target="#content">
<i class="fa-solid fa-times"></i>{% translate "Clear" %} <i class="fa-solid fa-times"></i>{% translate "Clear" %}
</a> </a>
{% endif %} {% endif %}
@@ -34,7 +35,7 @@
<div class="action_bar"> <div class="action_bar">
<div class="filter hidden lg:flex"> <div class="filter hidden lg:flex">
<form> <form hx-get="{% url "backend:members:list" %}" hx-target="#content">
{% for field in filter.form %} {% for field in filter.form %}
{% form_field field show_label=False size="extra-small" %} {% form_field field show_label=False size="extra-small" %}
{% endfor %} {% endfor %}
@@ -45,7 +46,7 @@
</button> </button>
{% if filter.is_bound %} {% if filter.is_bound %}
<a class="btn btn-outline btn-error btn-xs grow" href="{% url "backend:members:list" %}"> <a class="btn btn-outline btn-error btn-xs grow" href="{% url "backend:members:list" %}" hx-get="{% url "backend:members:list" %}" hx-target="#content">
<i class="fa-solid fa-times"></i>{% translate "Clear" %} <i class="fa-solid fa-times"></i>{% translate "Clear" %}
</a> </a>
{% endif %} {% endif %}
@@ -169,10 +170,10 @@
<div class="flex justify-center mt-4"> <div class="flex justify-center mt-4">
<div class="join"> <div class="join">
{% if page_obj.has_previous %} {% if page_obj.has_previous %}
<a class="join-item btn" href="?{% url_replace request "page" 1 %}">&laquo;<span <a class="join-item btn" href="?{% url_replace request "page" 1 %}" hx-get="?{% url_replace request "page" 1 %}" hx-target="#content">&laquo;<span
class="hidden lg:inline"> {% translate "first" %}</span></a> class="hidden lg:inline"> {% translate "first" %}</span></a>
<a class="join-item btn" <a class="join-item btn"
href="?{% url_replace request "page" page_obj.previous_page_number %}"><span href="?{% url_replace request "page" page_obj.previous_page_number %}" hx-get="?{% url_replace request "page" page_obj.previous_page_number %}" hx-target="#content"><span
class="hidden lg:inline">{% translate "previous" %}</span><span class="hidden lg:inline">{% translate "previous" %}</span><span
class="lg:hidden">&lt;</span></a> class="lg:hidden">&lt;</span></a>
{% endif %} {% endif %}
@@ -182,13 +183,14 @@
of {{ num_pages }}{% endblocktranslate %}</button> of {{ num_pages }}{% endblocktranslate %}</button>
{% if page_obj.has_next %} {% if page_obj.has_next %}
<a class="join-item btn" href="?{% url_replace request "page" page_obj.next_page_number %}"><span <a class="join-item btn" href="?{% url_replace request "page" page_obj.next_page_number %}" hx-get="?{% url_replace request "page" page_obj.next_page_number %}" hx-target="#content"><span
class="hidden lg:inline">{% translate "next" %}</span><span class="hidden lg:inline">{% translate "next" %}</span><span
class="lg:hidden">&gt;</span></a> class="lg:hidden">&gt;</span></a>
<a class="join-item btn" href="?{% url_replace request "page" page_obj.paginator.num_pages %}"><span <a class="join-item btn" href="?{% url_replace request "page" page_obj.paginator.num_pages %}" hx-get="?{% url_replace request "page" page_obj.num_pages %}" hx-target="#content"><span
class="hidden lg:inline"> {% translate "last" %}</span> &raquo;</a> class="hidden lg:inline"> {% translate "last" %}</span> &raquo;</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% endpartialdef content %}
{% endblock content %} {% endblock content %}