From d2d50afdd7f57b0f81528af7cdb6e12db8c39090 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Sat, 10 Jan 2026 23:57:06 +0100 Subject: [PATCH] Enhance MemberListView with HTMX integration, refactor member filter template for partial rendering, and add HTMX utility mixins. --- backend/members/views.py | 5 +- backend/mixins.py | 93 ++++++++ templates/base.html | 2 +- templates/members/member_filter.html | 320 ++++++++++++++------------- 4 files changed, 258 insertions(+), 162 deletions(-) create mode 100644 backend/mixins.py diff --git a/backend/members/views.py b/backend/members/views.py index 124f05f..a206ece 100644 --- a/backend/members/views.py +++ b/backend/members/views.py @@ -10,12 +10,13 @@ from rules.contrib.views import PermissionRequiredMixin from members.filters import MemberFilter from members.models import Member - -class MemberListView(PermissionRequiredMixin, FilterView): +from ..mixins import HTMXViewMixin +class MemberListView(HTMXViewMixin, PermissionRequiredMixin, FilterView): filterset_class = MemberFilter paginate_by = 50 permission_denied_message = _("You do not have permission to view this page.") permission_required = "members.view_member" + partial_name = "members/member_filter.html#content" def handle_no_permission(self) -> HttpResponseRedirect: messages.error(self.request, self.get_permission_denied_message()) diff --git a/backend/mixins.py b/backend/mixins.py new file mode 100644 index 0000000..a798796 --- /dev/null +++ b/backend/mixins.py @@ -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) \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 3401887..6f66ee7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -101,7 +101,7 @@
-
+
{% block content %}

Welcome!

This is your main content area.

diff --git a/templates/members/member_filter.html b/templates/members/member_filter.html index 60e10e7..7aba9d5 100644 --- a/templates/members/member_filter.html +++ b/templates/members/member_filter.html @@ -6,189 +6,191 @@ {% load pagination %} {% block content %} -

Members

+ {% partialdef content inline %} +

Members

-
- -
{% translate "Filter" %}{% if filter.is_bound %}active{% endif %}
-
-
- {% for field in filter.form %} - {% form_field field show_label=False size="small" %} - {% endfor %} +
+ +
{% translate "Filter" %}{% if filter.is_bound %}active{% endif %}
+
+ + {% for field in filter.form %} + {% form_field field show_label=False size="small" %} + {% endfor %} -
- +
+ - {% if filter.is_bound %} - - {% translate "Clear" %} - - {% endif %} -
- -
-
- -
- + +
-
- +
+ + +
-
- - - {% translate "Delete" %} - -
- - + + {% endif %} +
-
- {% if object_list|length == 0 %} -
{% translate "No members found" %}
- {% else %} -
- {% for member in object_list %} - -
- {% avatar first_name=member.user.first_name last_name=member.user.last_name width="sm" %} -
+ {% if is_paginated %} +
+
+ {% if page_obj.has_previous %} + « + < + {% endif %} -
-
{{ member.user.get_full_name }} {% if member.license %}#{{ member.license }}{% endif %}
-
{{ member.birthday|date:"d M Y"|default:"" }}
-
+ -
- -
- - {% endfor %} + {% if page_obj.has_next %} + > + » + {% endif %} +
{% endif %} -
- - {% if is_paginated %} -
-
- {% if page_obj.has_previous %} - « - < - {% endif %} - - - - {% if page_obj.has_next %} - > - » - {% endif %} -
-
- {% endif %} + {% endpartialdef content %} {% endblock content %} \ No newline at end of file