django-htmx-plus 0.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_htmx_plus/__init__.py +0 -0
- django_htmx_plus/http.py +47 -0
- django_htmx_plus/middleware.py +70 -0
- django_htmx_plus/mixins.py +41 -0
- django_htmx_plus/static/django_htmx_plus/django-htmx-plus.js +39 -0
- django_htmx_plus/templates/cotton/icons/chevron_down.html +6 -0
- django_htmx_plus/templates/cotton/icons/chevron_left.html +6 -0
- django_htmx_plus/templates/cotton/icons/chevron_right.html +6 -0
- django_htmx_plus/templates/cotton/icons/chevron_up.html +6 -0
- django_htmx_plus/templates/cotton/tables/head.html +5 -0
- django_htmx_plus/templates/cotton/tables/header_cell.html +15 -0
- django_htmx_plus/templates/cotton/tables/htmx_table.html +22 -0
- django_htmx_plus/templates/cotton/tables/pager.html +35 -0
- django_htmx_plus/templatetags/__init__.py +0 -0
- django_htmx_plus/templatetags/cotton_extras.py +17 -0
- django_htmx_plus/templatetags/django_htmx_plus.py +16 -0
- django_htmx_plus/types.py +21 -0
- django_htmx_plus/utils.py +111 -0
- django_htmx_plus/views.py +168 -0
- django_htmx_plus-0.0.0.dist-info/METADATA +359 -0
- django_htmx_plus-0.0.0.dist-info/RECORD +24 -0
- django_htmx_plus-0.0.0.dist-info/WHEEL +4 -0
- django_htmx_plus-0.0.0.dist-info/entry_points.txt +4 -0
- django_htmx_plus-0.0.0.dist-info/licenses/LICENSE.md +21 -0
|
File without changes
|
django_htmx_plus/http.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from django.http import HttpResponse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HtmxResponse(HttpResponse):
|
|
7
|
+
"""An HTTP 204 response that carries HTMX trigger events.
|
|
8
|
+
|
|
9
|
+
Returns a ``204 No Content`` status and populates the ``HX-Trigger``
|
|
10
|
+
response header with one or more comma-separated event names, causing
|
|
11
|
+
HTMX to fire those events on the client and trigger any listening elements
|
|
12
|
+
to refresh without a full page reload.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, triggers: List[str], *args, **kwargs):
|
|
16
|
+
"""Initialise the response with the given trigger event names.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
triggers (List[str]): Event names to include in the ``HX-Trigger``
|
|
20
|
+
response header.
|
|
21
|
+
*args: Additional positional arguments forwarded to ``HttpResponse``.
|
|
22
|
+
**kwargs: Additional keyword arguments forwarded to ``HttpResponse``.
|
|
23
|
+
"""
|
|
24
|
+
super(HtmxResponse, self).__init__(*args, **kwargs)
|
|
25
|
+
self.status_code = 204
|
|
26
|
+
self.headers["HX-Trigger"] = ",".join(triggers)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class HtmxRedirectResponse(HttpResponse):
|
|
30
|
+
"""An HTTP response that instructs HTMX to perform a client-side redirect.
|
|
31
|
+
|
|
32
|
+
Populates the ``HX-Redirect`` response header with the supplied destination
|
|
33
|
+
URL, causing HTMX to navigate the browser to that URL without a traditional
|
|
34
|
+
HTTP redirect status code.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, destination: str, *args, **kwargs):
|
|
38
|
+
"""Initialise the response with the redirect destination URL.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
destination (str): The URL to redirect the client to via the
|
|
42
|
+
``HX-Redirect`` response header.
|
|
43
|
+
*args: Additional positional arguments forwarded to ``HttpResponse``.
|
|
44
|
+
**kwargs: Additional keyword arguments forwarded to ``HttpResponse``.
|
|
45
|
+
"""
|
|
46
|
+
super(HtmxRedirectResponse, self).__init__(*args, **kwargs)
|
|
47
|
+
self.headers["HX-Redirect"] = destination
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from django.contrib.messages import get_messages
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class HtmxMessagesMiddleware:
|
|
6
|
+
"""Middleware that forwards Django messages to HTMX via the HX-Trigger header.
|
|
7
|
+
|
|
8
|
+
Intercepts HTMX requests and appends any pending Django messages to the
|
|
9
|
+
``HX-Trigger`` response header as a ``messages`` key, allowing the
|
|
10
|
+
client-side HTMX event system to display them without a full page reload.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, get_response):
|
|
14
|
+
"""Initialize the middleware.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
get_response: The next middleware or view in the chain.
|
|
18
|
+
"""
|
|
19
|
+
self.get_response = get_response
|
|
20
|
+
|
|
21
|
+
def __call__(self, request):
|
|
22
|
+
"""Process the request and attach messages to the HX-Trigger header.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
request: The incoming HTTP request.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
HttpResponse: The response with the ``HX-Trigger`` header updated
|
|
29
|
+
to include any pending Django messages when the request was made
|
|
30
|
+
by HTMX and the response is not a redirect.
|
|
31
|
+
"""
|
|
32
|
+
response = self.get_response(request)
|
|
33
|
+
|
|
34
|
+
# The HX-Request header indicates that the request was made with HTMX
|
|
35
|
+
if "HX-Request" not in request.headers:
|
|
36
|
+
return response
|
|
37
|
+
|
|
38
|
+
# Ignore redirections because HTMX cannot read the headers
|
|
39
|
+
if 300 <= response.status_code < 400:
|
|
40
|
+
return response
|
|
41
|
+
|
|
42
|
+
# Extract the messages
|
|
43
|
+
messages = [{"message": message.message, "tags": message.tags} for message in get_messages(request)]
|
|
44
|
+
if not messages:
|
|
45
|
+
return response
|
|
46
|
+
|
|
47
|
+
# Get the existing HX-Trigger that could have been defined by the view
|
|
48
|
+
hx_trigger = response.headers.get("HX-Trigger")
|
|
49
|
+
|
|
50
|
+
if hx_trigger is None:
|
|
51
|
+
# If the HX-Trigger is not set, start with an empty object
|
|
52
|
+
hx_trigger = {}
|
|
53
|
+
elif hx_trigger.startswith("{"):
|
|
54
|
+
# If the HX-Trigger uses the object syntax, parse the object
|
|
55
|
+
hx_trigger = json.loads(hx_trigger)
|
|
56
|
+
else:
|
|
57
|
+
# If the HX-Trigger uses the string syntax, convert to the object syntax
|
|
58
|
+
triggers = hx_trigger.split(",")
|
|
59
|
+
hx_trigger = {}
|
|
60
|
+
for trigger in triggers:
|
|
61
|
+
hx_trigger[trigger] = True
|
|
62
|
+
|
|
63
|
+
# Add the messages array in the HX-Trigger object
|
|
64
|
+
hx_trigger["messages"] = messages
|
|
65
|
+
|
|
66
|
+
# Add or update the HX-Trigger
|
|
67
|
+
response.headers["HX-Trigger"] = json.dumps(hx_trigger)
|
|
68
|
+
|
|
69
|
+
return response
|
|
70
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from django_htmx_plus.http import HtmxResponse
|
|
2
|
+
from django.contrib import messages
|
|
3
|
+
from typing import List
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class HtmxFormResponseMixin:
|
|
7
|
+
"""A mixin that provides a trigger-aware HTMX response for successful form submissions.
|
|
8
|
+
|
|
9
|
+
Mix this class into any ``FormView`` or ``CreateView`` subclass to replace the
|
|
10
|
+
default redirect response with an :class:`~django_htmx_plus.http.HtmxResponse`
|
|
11
|
+
that carries one or more ``HX-Trigger`` events, allowing the page to react
|
|
12
|
+
without a full navigation.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
valid_triggers (List[str]): Event names included in the ``HX-Trigger`` header
|
|
16
|
+
of the response returned after a successful form submission.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
valid_triggers: List[str] = []
|
|
20
|
+
success_message: str = ''
|
|
21
|
+
|
|
22
|
+
def form_valid(self, form):
|
|
23
|
+
"""Save the form and return a trigger-aware HTMX response.
|
|
24
|
+
|
|
25
|
+
Calls ``form.save()`` and then returns an :class:`~django_htmx_plus.http.HtmxResponse`
|
|
26
|
+
that sends all events listed in :attr:`valid_triggers` via the ``HX-Trigger``
|
|
27
|
+
response header, prompting any listening HTMX elements to refresh.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
form (forms.ModelForm): A bound and validated Django form instance.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
HtmxResponse: A 204 response containing the ``HX-Trigger`` header populated
|
|
34
|
+
with the events defined in :attr:`valid_triggers`.
|
|
35
|
+
"""
|
|
36
|
+
form.save()
|
|
37
|
+
|
|
38
|
+
if self.success_message:
|
|
39
|
+
messages.success(self.request, self.success_message)
|
|
40
|
+
|
|
41
|
+
return HtmxResponse(self.valid_triggers)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {Modal, Offcanvas} from 'bootstrap';
|
|
2
|
+
|
|
3
|
+
const dialogs = {}
|
|
4
|
+
|
|
5
|
+
for (const element of document.querySelectorAll('[data-htmx-plus-modal]')) {
|
|
6
|
+
dialogs[element.dataset.htmxPlusModal] = new Modal(element);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
for (const element of document.querySelectorAll('[data-htmx-plus-offcanvas]')) {
|
|
10
|
+
dialogs[element.dataset.htmxPlusOffcanvas] = new Offcanvas(element);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
htmx.on("htmx:afterSwap", (e) => {
|
|
14
|
+
// Response targeting #dialog => show the modal
|
|
15
|
+
const element_id = e.detail.target.id;
|
|
16
|
+
|
|
17
|
+
if(element_id in dialogs) {
|
|
18
|
+
dialogs[element_id].show();
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
htmx.on("htmx:beforeSwap", (e) => {
|
|
23
|
+
// Empty response targeting #dialog => hide the modal
|
|
24
|
+
const element_id = e.detail.target.id;
|
|
25
|
+
|
|
26
|
+
if(element_id in dialogs && !e.detail.xhr.response) {
|
|
27
|
+
dialogs[element_id].hide();
|
|
28
|
+
e.detail.shouldSwap = false;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
htmx.on("hidden.bs.modal", (e) => {
|
|
33
|
+
// This resets modal body to ""
|
|
34
|
+
document.getElementById(e.target.id).firstElementChild.innerHTML = "";
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
htmx.on("hidden.bs.offcanvas", (e) => {
|
|
38
|
+
console.log("offcanvas", e);
|
|
39
|
+
})
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
2
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
3
|
+
class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-down {{ add_class }}" {{ attrs }}>
|
|
4
|
+
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
5
|
+
<path d="M6 9l6 6l6 -6"/>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
2
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
3
|
+
class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-left {{ add_class }}" {{ attrs }}>
|
|
4
|
+
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
5
|
+
<path d="M15 6l-6 6l6 6"/>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
2
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
3
|
+
class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-right {{ add_class }}" {{ attrs }}>
|
|
4
|
+
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
5
|
+
<path d="M9 6l6 6l-6 6"/>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
2
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
3
|
+
class="icon icon-tabler icons-tabler-outline icon-tabler-chevron-up {{ add_class }}" {{ attrs }}>
|
|
4
|
+
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
|
|
5
|
+
<path d="M6 15l6 -6l6 6"/>
|
|
6
|
+
</svg>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<th class="cursor-pointer {{ add_class }}" hx-get="{{ path }}{% if page_obj %}?page={{ page_obj.number }}&{% else %}?{% endif %}{% if filter_query %}{{ filter_query }}&{% endif %}order_by={% if name == order_by %}-{{ name }}{% else %}{{ name }}{% endif %}"
|
|
2
|
+
hx-target="{{ target_id }}">
|
|
3
|
+
<div class="d-flex">
|
|
4
|
+
<div class="flex-grow-1 align-bottom mt-1">
|
|
5
|
+
{{ slot }}
|
|
6
|
+
</div>
|
|
7
|
+
{% if order_by %}
|
|
8
|
+
{% if name == order_by %}
|
|
9
|
+
<c-icons.chevron_up add_class="ms-1"/>
|
|
10
|
+
{% elif "-"|add:name == order_by %}
|
|
11
|
+
<c-icons.chevron_down add_class="ms-1"/>
|
|
12
|
+
{% endif %}
|
|
13
|
+
{% endif %}
|
|
14
|
+
</div>
|
|
15
|
+
</th>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{% load cotton_extras %}
|
|
2
|
+
<div class="htmx-table-wrap">
|
|
3
|
+
<table class="{{class}}">
|
|
4
|
+
<thead>
|
|
5
|
+
{% for field in fields.keys %}
|
|
6
|
+
<c-tables.header_cell name="{{ field }}">{% if field in fields.labels %}{{ fields.labels|get_key_value:field }}{% else %}{{field|capfirst}}{% endif %}</c-tables.header_cell>
|
|
7
|
+
{% endfor %}
|
|
8
|
+
</thead>
|
|
9
|
+
<tbody>
|
|
10
|
+
{% for item in object_list %}
|
|
11
|
+
<tr>
|
|
12
|
+
{% for field in fields.keys %}
|
|
13
|
+
<td>{{ item|get_key_value:field }}</td>
|
|
14
|
+
{% endfor %}
|
|
15
|
+
</tr>
|
|
16
|
+
{% endfor %}
|
|
17
|
+
</tbody>
|
|
18
|
+
</table>
|
|
19
|
+
</div>
|
|
20
|
+
{% if paginator %}
|
|
21
|
+
<c-tables.pager />
|
|
22
|
+
{% endif %}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
|
|
2
|
+
<div class="d-flex justify-content-center justify-content-sm-between">
|
|
3
|
+
<div class="align-items-center">
|
|
4
|
+
<p class="m-0 text-secondary">Showing <strong>{{ page_obj.start_index }} to {{ page_obj.end_index }}</strong> of
|
|
5
|
+
<strong>{{ page_obj.paginator.count }} entries</strong></p>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="justify-content-end">
|
|
8
|
+
<ul class="pagination" hx-target="{{ target_id }}">
|
|
9
|
+
<li class="page-item {% if not page_obj.has_previous %}disabled{% endif %}">
|
|
10
|
+
<button type="button" class="page-link"
|
|
11
|
+
{% if page_obj.has_previous %}hx-get="{{ request.path }}?page={{ page_obj.previous_page_number }}&{{ query }}"{% endif %}
|
|
12
|
+
tabindex="-1" aria-disabled="true">
|
|
13
|
+
<c-icons.chevron_left/>
|
|
14
|
+
</button>
|
|
15
|
+
</li>
|
|
16
|
+
{% for page in page_range %}
|
|
17
|
+
<li class="page-item {% if page_obj.number == page %}active{% endif %}">
|
|
18
|
+
{% if page == page_obj.paginator.ELLIPSIS %}
|
|
19
|
+
<span class="page-link h-100">{{ page }}</span>
|
|
20
|
+
{% else %}
|
|
21
|
+
<button type="button" class="h-100 page-link {% if page_obj.number == page %}active{% endif %}"
|
|
22
|
+
hx-get="{{ request.path }}?page={{ page }}&{{ query }}">{{ page }}</button>
|
|
23
|
+
{% endif %}
|
|
24
|
+
</li>
|
|
25
|
+
{% endfor %}
|
|
26
|
+
<li class="page-item {% if not page_obj.has_next %}disabled{% endif %}">
|
|
27
|
+
<button type="button" class="page-link"
|
|
28
|
+
{% if page_obj.has_next %}hx-get="{{ request.path }}?page={{ page_obj.next_page_number }}&{{ query }}"{% endif %}
|
|
29
|
+
tabindex="-1" aria-disabled="true">
|
|
30
|
+
<c-icons.chevron_right/>
|
|
31
|
+
</button>
|
|
32
|
+
</li>
|
|
33
|
+
</ul>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from django import template
|
|
2
|
+
from typing import Dict, Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
register = template.Library()
|
|
6
|
+
|
|
7
|
+
@register.filter(name="get_attr")
|
|
8
|
+
def get_attr(obj, attr_name):
|
|
9
|
+
if not hasattr(obj, attr_name):
|
|
10
|
+
return ''
|
|
11
|
+
|
|
12
|
+
return getattr(obj, attr_name)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@register.filter(name="get_key_value")
|
|
16
|
+
def get_key_value(obj: Dict[str, Any], key: str):
|
|
17
|
+
return obj.get(key, None)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from django.utils.safestring import SafeString
|
|
2
|
+
from django.utils.html import format_html
|
|
3
|
+
from django.templatetags.static import static
|
|
4
|
+
from django.template import Library, Context
|
|
5
|
+
|
|
6
|
+
register = Library()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@register.simple_tag(takes_context=True)
|
|
10
|
+
def htmx_plus_script(context: Context) -> str:
|
|
11
|
+
parts = [
|
|
12
|
+
static("django_htmx_plus/django-htmx-plus.js"),
|
|
13
|
+
(context.get("nonce", None) or "")
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
return format_html('<script src="{}" type="module"{}></script>', *parts)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from enum import StrEnum
|
|
2
|
+
|
|
3
|
+
class Filter(StrEnum):
|
|
4
|
+
EQ = 'exact'
|
|
5
|
+
IEQ = 'iexact'
|
|
6
|
+
GT = 'gt'
|
|
7
|
+
GTE = 'gte'
|
|
8
|
+
LT = 'lt'
|
|
9
|
+
LTE = 'lte'
|
|
10
|
+
LIKE = 'like'
|
|
11
|
+
ILIKE = 'ilike'
|
|
12
|
+
CS = 'cs'
|
|
13
|
+
ICS = 'ics'
|
|
14
|
+
IN = 'in'
|
|
15
|
+
NL = 'isnull'
|
|
16
|
+
RNG = 'range'
|
|
17
|
+
EW = 'endswith'
|
|
18
|
+
IEW = 'iendswith'
|
|
19
|
+
SW = 'startswith'
|
|
20
|
+
ISW = 'istartswith'
|
|
21
|
+
SCH = 'search'
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
from typing import List, Dict, Any, Tuple
|
|
3
|
+
|
|
4
|
+
from django_htmx_plus.types import Filter
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_filter_dict(query_params: Dict[str, Any], allowed_fields: Tuple[str, ...] | None) -> dict:
|
|
8
|
+
"""Build a filter dictionary from query parameters, restricted to allowed fields.
|
|
9
|
+
|
|
10
|
+
Parses query parameters of the form ``field_name.filter_type=value`` and
|
|
11
|
+
converts them into a Django ORM-compatible filter dict. Only fields present
|
|
12
|
+
in ``allowed_fields`` are processed; unknown filter suffixes are silently
|
|
13
|
+
dropped rather than forwarded to the ORM.
|
|
14
|
+
|
|
15
|
+
The ``field_name`` portion is the *root* field name only — traversal via
|
|
16
|
+
``__`` within the field name segment is not permitted. This prevents callers
|
|
17
|
+
from bypassing the allowlist via related-model lookups (e.g.
|
|
18
|
+
``related__secret.eq=value``).
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
query_params (dict): A dictionary of raw query parameters, typically
|
|
22
|
+
``request.GET``.
|
|
23
|
+
allowed_fields (Tuple[str, ...] | None): A tuple of permitted field names.
|
|
24
|
+
Pass ``("__all__",)`` or an empty tuple/``None`` to allow all fields.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
dict: A dictionary suitable for passing directly to ``QuerySet.filter()``.
|
|
28
|
+
"""
|
|
29
|
+
filter_dict = {}
|
|
30
|
+
unrestricted = not allowed_fields or "__all__" in allowed_fields
|
|
31
|
+
for key, value in query_params.items():
|
|
32
|
+
if "." in key:
|
|
33
|
+
field_name, filter_type = key.split(".", 1)
|
|
34
|
+
# Block traversal: field_name must be a plain field, not a __ path
|
|
35
|
+
if "__" in field_name:
|
|
36
|
+
continue
|
|
37
|
+
if unrestricted or field_name in allowed_fields:
|
|
38
|
+
if filter_type.upper() in Filter.__members__:
|
|
39
|
+
filter_value = Filter.__members__[filter_type.upper()].value
|
|
40
|
+
if isinstance(value, str):
|
|
41
|
+
try:
|
|
42
|
+
filter_dict[f"{field_name}__{filter_value}"] = ast.literal_eval(value)
|
|
43
|
+
except ValueError:
|
|
44
|
+
filter_dict[f"{field_name}__{filter_value}"] = value
|
|
45
|
+
else:
|
|
46
|
+
filter_dict[f"{field_name}__{filter_value}"] = value
|
|
47
|
+
# Unknown filter suffixes are silently ignored instead of being
|
|
48
|
+
# forwarded to the ORM with an invalid key.
|
|
49
|
+
return filter_dict
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_query_str(query_params: dict) -> str:
|
|
53
|
+
"""Build a URL query string from filter-style query parameters.
|
|
54
|
+
|
|
55
|
+
Iterates over ``query_params`` and includes only entries whose key contains
|
|
56
|
+
a ``.`` (i.e. ``field_name.filter_type`` pairs). The ``order_by`` and other
|
|
57
|
+
plain parameters are intentionally excluded.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
query_params (dict): A dictionary of raw query parameters, typically
|
|
61
|
+
``request.GET``.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
str: An ampersand-joined query string of the form
|
|
65
|
+
``field.filter=value&field2.filter=value2``, or an empty string if
|
|
66
|
+
no matching parameters are present.
|
|
67
|
+
"""
|
|
68
|
+
items = []
|
|
69
|
+
for key, value in query_params.items():
|
|
70
|
+
if "." in key:
|
|
71
|
+
items.append(f"{key}={value}")
|
|
72
|
+
return "&".join(items)
|
|
73
|
+
|
|
74
|
+
def build_filters_template_dict(filter: dict) -> dict:
|
|
75
|
+
"""Convert an ORM filter dict into a template-friendly mapping.
|
|
76
|
+
|
|
77
|
+
Strips the Django ORM lookup suffix (everything from the first ``__``
|
|
78
|
+
onward) from each key so that templates receive plain field names as keys.
|
|
79
|
+
For example, ``{"name__icontains": "Alice"}`` becomes ``{"name": "Alice"}``.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
filter (dict): A dictionary of ORM filter expressions as returned by
|
|
83
|
+
:func:`build_filter_dict`.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
dict: A dictionary mapping plain field names to their filter values.
|
|
87
|
+
"""
|
|
88
|
+
results = {}
|
|
89
|
+
for key, value in filter.items():
|
|
90
|
+
results[key.split("__")[0]] = value
|
|
91
|
+
return results
|
|
92
|
+
|
|
93
|
+
def split_and_strip(content: str) -> List[str]:
|
|
94
|
+
"""Split and normalise a multi-line string into a list of non-empty lines.
|
|
95
|
+
|
|
96
|
+
Strips leading/trailing spaces from each line, normalises Windows-style
|
|
97
|
+
line endings (``\\r\\n``) to Unix-style (``\\n``), removes non-breaking
|
|
98
|
+
space characters (``\\xa0`` and ``\\xc2``), and discards any lines that are
|
|
99
|
+
empty after stripping.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
content (str): The raw multi-line string to process.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
List[str]: A list of cleaned, non-empty lines from ``content``.
|
|
106
|
+
"""
|
|
107
|
+
lines = [
|
|
108
|
+
line.strip(" ").replace("\xa0", "").replace("\xc2", "")
|
|
109
|
+
for line in content.strip(" ").replace("\r\n", "\n").split("\n")
|
|
110
|
+
]
|
|
111
|
+
return [line for line in lines if line]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from django.views.generic import ListView
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
from urllib.parse import urlencode
|
|
4
|
+
|
|
5
|
+
from django_htmx_plus.utils import build_filter_dict, build_query_str, build_filters_template_dict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HtmxListView(ListView):
|
|
9
|
+
"""A Django ListView with HTMX support for filtering, sorting, and pagination.
|
|
10
|
+
|
|
11
|
+
Extends Django's ListView to provide seamless HTMX integration, including
|
|
12
|
+
dynamic filtering, column-based ordering, and elided pagination with query
|
|
13
|
+
string preservation.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
target_id (str): The HTML element ID that HTMX will target for partial updates.
|
|
17
|
+
fields (Tuple[str, ...]): A tuple of model field names to include in the queryset
|
|
18
|
+
values. Use ``("__all__",)`` or leave empty to return full model instances.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
target_id = ""
|
|
22
|
+
fields: Tuple[str, ...] = ()
|
|
23
|
+
labels: {}
|
|
24
|
+
|
|
25
|
+
def __init__(self):
|
|
26
|
+
"""Initialise instance-level defaults for filter, query, and ordering state.
|
|
27
|
+
|
|
28
|
+
Sets safe default values so that attributes are always defined, even if
|
|
29
|
+
:meth:`setup` has not yet been called (e.g. during class introspection or
|
|
30
|
+
testing). All values are overwritten by :meth:`setup` on every real request.
|
|
31
|
+
"""
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.filter = {}
|
|
34
|
+
self.query = ""
|
|
35
|
+
self.filter_query = ""
|
|
36
|
+
self.order_by = "pk"
|
|
37
|
+
|
|
38
|
+
def setup(self, request, *args, **kwargs):
|
|
39
|
+
"""Initialize view attributes from the incoming request.
|
|
40
|
+
|
|
41
|
+
Parses query parameters to build filter dictionaries, query strings, and
|
|
42
|
+
the active ordering field. Falls back to ``"pk"`` if no ordering is specified.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
request: The incoming HTTP request object.
|
|
46
|
+
*args: Additional positional arguments passed to the parent ``setup``.
|
|
47
|
+
**kwargs: Additional keyword arguments passed to the parent ``setup``.
|
|
48
|
+
"""
|
|
49
|
+
super(HtmxListView, self).setup(request, *args, **kwargs)
|
|
50
|
+
self.filter = build_filter_dict(request.GET, self.fields)
|
|
51
|
+
self.query = build_query_str(request.GET)
|
|
52
|
+
self.filter_query = self.query
|
|
53
|
+
self.order_by = getattr(self, "order_by", None)
|
|
54
|
+
|
|
55
|
+
if "order_by" in request.GET:
|
|
56
|
+
candidate = request.GET.get('order_by')
|
|
57
|
+
if self._is_order_by_allowed(candidate):
|
|
58
|
+
self.order_by = candidate
|
|
59
|
+
|
|
60
|
+
if not self.order_by:
|
|
61
|
+
self.order_by = "pk"
|
|
62
|
+
|
|
63
|
+
self.query += f"&order_by={self.order_by}"
|
|
64
|
+
|
|
65
|
+
def get_queryset(self):
|
|
66
|
+
"""Return the filtered and ordered queryset for the view.
|
|
67
|
+
|
|
68
|
+
Applies any active filters from ``self.filter`` to the base queryset and
|
|
69
|
+
orders the result by ``self.order_by``. When ``fields`` is non-empty and
|
|
70
|
+
does not contain ``"__all__"``, the queryset is returned as a
|
|
71
|
+
``ValuesQuerySet`` restricted to those fields, ensuring that columns not
|
|
72
|
+
listed in ``fields`` are never fetched from the database.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
QuerySet: A filtered and ordered queryset, or a ``ValuesQuerySet``
|
|
76
|
+
limited to ``self.fields`` when an explicit field list is provided.
|
|
77
|
+
"""
|
|
78
|
+
qs = super(HtmxListView, self).get_queryset()
|
|
79
|
+
|
|
80
|
+
if hasattr(self, 'filter') and self.filter:
|
|
81
|
+
qs = qs.filter(**self.filter)
|
|
82
|
+
if hasattr(self, 'order_by') and self.order_by:
|
|
83
|
+
qs = qs.order_by(self.order_by)
|
|
84
|
+
|
|
85
|
+
if not self.fields or "__all__" in self.fields:
|
|
86
|
+
return qs
|
|
87
|
+
|
|
88
|
+
return qs.values(*self.fields)
|
|
89
|
+
|
|
90
|
+
def _is_order_by_allowed(self, order_by: str) -> bool:
|
|
91
|
+
"""Check whether a user-supplied ordering field is permitted.
|
|
92
|
+
|
|
93
|
+
The field name is extracted by stripping a leading ``-`` (descending
|
|
94
|
+
indicator). The field is then checked against ``self.fields``, so callers
|
|
95
|
+
cannot bypass the allowlist via related-model traversal (e.g. ``allowed_field__secret``).
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
order_by (str): The raw ``order_by`` value from the query string,
|
|
99
|
+
optionally prefixed with ``-`` for descending order.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
bool: ``True`` if the field is permitted or no allowlist is active,
|
|
103
|
+
``False`` otherwise.
|
|
104
|
+
"""
|
|
105
|
+
if not self.fields:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
if self.fields and "__all__" in self.fields:
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
field = order_by.lstrip('-')
|
|
112
|
+
return field in self.fields
|
|
113
|
+
|
|
114
|
+
def get_context_data(self, **kwargs):
|
|
115
|
+
"""Build and return the template context dictionary.
|
|
116
|
+
|
|
117
|
+
Adds HTMX- and pagination-related context variables, including the current
|
|
118
|
+
query string (without the ``page`` parameter), elided page range, ordering
|
|
119
|
+
state, request path, and active filters.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
**kwargs: Additional keyword arguments forwarded to the parent
|
|
123
|
+
``get_context_data``.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
dict: The context dictionary containing the following extra keys:
|
|
127
|
+
|
|
128
|
+
- ``query_params`` (str): URL-encoded query string excluding ``page``.
|
|
129
|
+
- ``page_range`` (iterator): Elided page range (only present when
|
|
130
|
+
``paginate_by`` is set).
|
|
131
|
+
- ``order_by`` (str): The currently active ordering field.
|
|
132
|
+
- ``path`` (str): The current request path.
|
|
133
|
+
- ``target_id`` (str): The HTMX target element ID.
|
|
134
|
+
- ``query`` (str): Full query string including the ``order_by`` parameter.
|
|
135
|
+
- ``filter_query`` (str): Query string containing only filter parameters.
|
|
136
|
+
- ``filters`` (dict): Template-ready representation of active filters.
|
|
137
|
+
"""
|
|
138
|
+
context = super().get_context_data(**kwargs)
|
|
139
|
+
query_params = {p: v for p, v in self.request.GET.items() if p != 'page'}
|
|
140
|
+
context["query_params"] = urlencode(query_params, doseq=True)
|
|
141
|
+
|
|
142
|
+
if hasattr(self, 'paginate_by') and self.paginate_by:
|
|
143
|
+
if not hasattr(self, 'elided_each_side'):
|
|
144
|
+
self.elided_each_side = 1
|
|
145
|
+
|
|
146
|
+
if not hasattr(self, 'elided_ends'):
|
|
147
|
+
self.elided_ends = 1
|
|
148
|
+
|
|
149
|
+
context["page_range"] = context["paginator"].get_elided_page_range(
|
|
150
|
+
number=context["page_obj"].number, on_each_side=self.elided_each_side, on_ends=self.elided_ends
|
|
151
|
+
)
|
|
152
|
+
context["paginator"].allow_empty_first_page = True
|
|
153
|
+
|
|
154
|
+
context["order_by"] = self.order_by
|
|
155
|
+
context["path"] = self.request.path
|
|
156
|
+
context["target_id"] = self.target_id
|
|
157
|
+
context["query"] = self.query
|
|
158
|
+
context["filter_query"] = self.filter_query
|
|
159
|
+
context["filters"] = build_filters_template_dict(self.filter)
|
|
160
|
+
context["object_list"] = list(context["object_list"])
|
|
161
|
+
|
|
162
|
+
fields = {
|
|
163
|
+
"keys": self.fields,
|
|
164
|
+
"labels": self.labels,
|
|
165
|
+
}
|
|
166
|
+
context["fields"] = fields
|
|
167
|
+
|
|
168
|
+
return context
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-htmx-plus
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: django-htmx plus some extras
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE.md
|
|
7
|
+
Author: Tim Davis
|
|
8
|
+
Author-email: binary.god@gmail.com
|
|
9
|
+
Requires-Python: >=3.11,<4
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Dist: django-cotton (>=2.6.2,<3.0.0)
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# django-htmx-plus
|
|
20
|
+
|
|
21
|
+
A Django utility library that works with [django-cotton](https://github.com/wrabit/django-cotton) to provide ready-made views, mixins, middleware, response helpers, and components for building HTMX-powered list views with filtering, sorting, and pagination.
|
|
22
|
+
|
|
23
|
+
This package was created for my own projects but it was handy enough to share, I learned how to use HTMX with Django and Boostrap Modals from the following two posts from [Josh Karamuth](https://www.linkedin.com/in/josh-karamuth/):
|
|
24
|
+
* [How to show a modal in Django + HTMX](https://joshkaramuth.com/blog/django-htmx-modal/)
|
|
25
|
+
* [Show Django forms inside a modal using HTMX](https://joshkaramuth.com/blog/django-htmx-modal-forms/)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- **`HtmxResponse`** – a 204 No Content response that fires `HX-Trigger` events on the client.
|
|
32
|
+
- **`HtmxRedirectResponse`** – a response that sends an `HX-Redirect` header to navigate the browser.
|
|
33
|
+
- **`HtmxMessagesMiddleware`** – automatically forwards Django messages to the client via the `HX-Trigger` header on every HTMX request.
|
|
34
|
+
- **`HtmxFormResponseMixin`** – a `FormView`/`CreateView` mixin that replaces the success redirect with an HTMX trigger response.
|
|
35
|
+
- **`HtmxListView`** – a `ListView` subclass with built-in URL-based filtering, column sorting, and elided pagination.
|
|
36
|
+
- **Filter helpers** – parse `field.filter_type=value` query parameters safely into Django ORM filter dicts.
|
|
37
|
+
- **Cotton components** – drop-in table, header cell, and pager components styled for Bootstrap 5.
|
|
38
|
+
- **Template filters** – `get_attr` and `get_key_value` for accessing object attributes and dict values in templates.
|
|
39
|
+
- **Built-in JavaScript helper** – a `django-htmx-plus.js` ES module that wires Bootstrap 5 Modals and Offcanvases to HTMX swap events, with a `{% htmx_plus_script %}` template tag to include it.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
|
|
45
|
+
- Python 3.11+
|
|
46
|
+
- Django (any version compatible with the above)
|
|
47
|
+
- [django-cotton](https://github.com/wrabit/django-cotton) ≥ 2.6
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install django-htmx-plus
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Add to `INSTALLED_APPS` and configure middleware in `settings.py`:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
INSTALLED_APPS = [
|
|
61
|
+
# ...
|
|
62
|
+
"django_cotton",
|
|
63
|
+
"django_htmx_plus",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
MIDDLEWARE = [
|
|
67
|
+
# ...
|
|
68
|
+
"django_htmx_plus.middleware.HtmxMessagesMiddleware",
|
|
69
|
+
]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
### HtmxResponse
|
|
77
|
+
|
|
78
|
+
Return a `204 No Content` response that triggers one or more HTMX events on the client:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from django_htmx_plus.http import HtmxResponse
|
|
82
|
+
|
|
83
|
+
def my_view(request):
|
|
84
|
+
# ... do some work ...
|
|
85
|
+
return HtmxResponse(triggers=["itemUpdated", "refreshStats"])
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The response sets `HX-Trigger: itemUpdated,refreshStats`, which HTMX picks up to re-fetch any elements listening for those events.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
### HtmxRedirectResponse
|
|
93
|
+
|
|
94
|
+
Instruct HTMX to navigate the browser to a new URL without a traditional HTTP redirect:
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from django_htmx_plus.http import HtmxRedirectResponse
|
|
98
|
+
|
|
99
|
+
def my_view(request):
|
|
100
|
+
return HtmxRedirectResponse(destination="/dashboard/")
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
---
|
|
104
|
+
|
|
105
|
+
### HtmxMessagesMiddleware
|
|
106
|
+
|
|
107
|
+
Once the middleware is installed, any Django message added during an HTMX request is automatically serialised into the `HX-Trigger` header as a `messages` key:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"messages": [
|
|
112
|
+
{"message": "Record saved.", "tags": "success"}
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
If the view already sets its own `HX-Trigger` header (either string or JSON object syntax), the middleware merges the messages in without overwriting existing triggers.
|
|
118
|
+
|
|
119
|
+
Listen for the `messages` event in your HTMX setup to display the messages, for example:
|
|
120
|
+
|
|
121
|
+
```javascript
|
|
122
|
+
document.body.addEventListener("messages", (event) => {
|
|
123
|
+
event.detail.value.forEach(({message, tags}) => {
|
|
124
|
+
showToast(message, tags); // your toast implementation
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
### HtmxFormResponseMixin
|
|
132
|
+
|
|
133
|
+
Mix into any `FormView` or `CreateView` to replace the default success redirect with an HTMX trigger response:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from django.views.generic.edit import CreateView
|
|
137
|
+
from django_htmx_plus.mixins import HtmxFormResponseMixin
|
|
138
|
+
from myapp.models import Article
|
|
139
|
+
from myapp.forms import ArticleForm
|
|
140
|
+
|
|
141
|
+
class ArticleCreateView(HtmxFormResponseMixin, CreateView):
|
|
142
|
+
model = Article
|
|
143
|
+
form_class = ArticleForm
|
|
144
|
+
template_name = "articles/form.html"
|
|
145
|
+
|
|
146
|
+
valid_triggers = ["articleCreated"]
|
|
147
|
+
success_message = "Article created successfully."
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
After a valid submission, `form.save()` is called, an optional Django success message is queued, and an `HtmxResponse` carrying `articleCreated` in `HX-Trigger` is returned.
|
|
151
|
+
|
|
152
|
+
| Attribute | Type | Description |
|
|
153
|
+
|---|---|---|
|
|
154
|
+
| `valid_triggers` | `List[str]` | Events to include in `HX-Trigger` on success. |
|
|
155
|
+
| `success_message` | `str` | Optional Django success message to queue. |
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
### HtmxListView
|
|
160
|
+
|
|
161
|
+
A drop-in replacement for Django's `ListView` that adds URL-driven filtering, sorting, and elided pagination:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from django.views.generic import ListView
|
|
165
|
+
from django_htmx_plus.views import HtmxListView
|
|
166
|
+
from myapp.models import Article
|
|
167
|
+
|
|
168
|
+
class ArticleListView(HtmxListView):
|
|
169
|
+
model = Article
|
|
170
|
+
template_name = "articles/list.html"
|
|
171
|
+
paginate_by = 20
|
|
172
|
+
target_id = "#article-table"
|
|
173
|
+
|
|
174
|
+
# Restrict filtering and sorting to these fields only
|
|
175
|
+
fields = ("id", "title", "status", "created_at")
|
|
176
|
+
|
|
177
|
+
# Optional custom column labels
|
|
178
|
+
labels = {
|
|
179
|
+
"created_at": "Date Created",
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
#### URL-based filtering
|
|
184
|
+
|
|
185
|
+
Filters are expressed as `field_name.filter_type=value` query parameters:
|
|
186
|
+
|
|
187
|
+
| Filter key | Django lookup | Example |
|
|
188
|
+
|---|---|---|
|
|
189
|
+
| `eq` | `exact` | `status.eq=published` |
|
|
190
|
+
| `ieq` | `iexact` | `title.ieq=hello` |
|
|
191
|
+
| `gt` / `gte` / `lt` / `lte` | `gt` / `gte` / `lt` / `lte` | `views.gte=100` |
|
|
192
|
+
| `like` / `ilike` | `like` / `ilike` | `title.ilike=django` |
|
|
193
|
+
| `sw` / `isw` | `startswith` / `istartswith` | `title.sw=Django` |
|
|
194
|
+
| `ew` / `iew` | `endswith` / `iendswith` | `title.ew=plus` |
|
|
195
|
+
| `in` | `in` | `status.in=['draft','published']` |
|
|
196
|
+
| `nl` | `isnull` | `deleted_at.nl=True` |
|
|
197
|
+
| `rng` | `range` | `created_at.rng=['2024-01-01','2024-12-31']` |
|
|
198
|
+
| `sch` | `search` | `body.sch=htmx` |
|
|
199
|
+
|
|
200
|
+
Only fields listed in `fields` are accepted. Setting `fields = ("__all__",)` lifts the restriction.
|
|
201
|
+
|
|
202
|
+
#### Sorting
|
|
203
|
+
|
|
204
|
+
Add `order_by=field_name` (or `order_by=-field_name` for descending) to the query string. Only fields in `fields` are permitted.
|
|
205
|
+
|
|
206
|
+
#### Context variables
|
|
207
|
+
|
|
208
|
+
| Variable | Description |
|
|
209
|
+
|---|---|
|
|
210
|
+
| `query_params` | URL-encoded query string (without `page`). |
|
|
211
|
+
| `page_range` | Elided page range for pagination controls. |
|
|
212
|
+
| `order_by` | The currently active ordering field. |
|
|
213
|
+
| `path` | The current request path. |
|
|
214
|
+
| `target_id` | The HTMX target element ID. |
|
|
215
|
+
| `query` | Full query string including `order_by`. |
|
|
216
|
+
| `filter_query` | Query string containing only filter parameters. |
|
|
217
|
+
| `filters` | Template-ready dict mapping plain field names to their filter values. |
|
|
218
|
+
| `fields` | Dict with `keys` (field names) and `labels` (display names). |
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
### Cotton Table Components
|
|
223
|
+
|
|
224
|
+
`django-htmx-plus` ships a set of [django-cotton](https://github.com/wrabit/django-cotton) components for rendering sortable, paginated HTMX tables with Bootstrap 5.
|
|
225
|
+
|
|
226
|
+
#### `<c-tables.htmx_table />`
|
|
227
|
+
|
|
228
|
+
Renders a full table with auto-generated sortable headers, rows, and an optional pager:
|
|
229
|
+
|
|
230
|
+
```html
|
|
231
|
+
{% load cotton_extras %}
|
|
232
|
+
|
|
233
|
+
<c-tables.htmx_table class="table table-striped" />
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
The component uses the `fields`, `objects`, `order_by`, `path`, `filter_query`, `target_id`, `page_obj`, and `paginator` context variables provided automatically by `HtmxListView`.
|
|
237
|
+
|
|
238
|
+
#### `<c-tables.header_cell name="field_name" />`
|
|
239
|
+
|
|
240
|
+
Renders a single `<th>` with an `hx-get` attribute that toggles ascending/descending order and a chevron icon indicating the current sort direction:
|
|
241
|
+
|
|
242
|
+
```html
|
|
243
|
+
<c-tables.head>
|
|
244
|
+
<c-tables.header_cell name="title">Title</c-tables.header_cell>
|
|
245
|
+
<c-tables.header_cell name="created_at">Date</c-tables.header_cell>
|
|
246
|
+
</c-tables.head>
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
#### `<c-tables.pager />`
|
|
250
|
+
|
|
251
|
+
Renders a Bootstrap 5 pagination control with previous/next buttons and elided page numbers, all wired to `hx-get`.
|
|
252
|
+
|
|
253
|
+
#### Icon components
|
|
254
|
+
|
|
255
|
+
| Component | Description |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `<c-icons.chevron_up />` | Up chevron (ascending sort indicator). |
|
|
258
|
+
| `<c-icons.chevron_down />` | Down chevron (descending sort indicator). |
|
|
259
|
+
| `<c-icons.chevron_left />` | Left chevron (previous page). |
|
|
260
|
+
| `<c-icons.chevron_right />` | Right chevron (next page). |
|
|
261
|
+
|
|
262
|
+
All icon components accept an optional `add_class` attribute to append extra CSS classes.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
### Template filters
|
|
267
|
+
|
|
268
|
+
Load the `cotton_extras` tag library in any template:
|
|
269
|
+
|
|
270
|
+
```html
|
|
271
|
+
{% load cotton_extras %}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
| Filter | Description | Example |
|
|
275
|
+
|---|---|---|
|
|
276
|
+
| `get_attr` | Get an attribute from an object by name. | `{{ item\|get_attr:"title" }}` |
|
|
277
|
+
| `get_key_value` | Get a value from a dict by key. | `{{ my_dict\|get_key_value:"name" }}` |
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
### Built-in JavaScript Helper
|
|
282
|
+
|
|
283
|
+
`django-htmx-plus` ships a small ES module (`django-htmx-plus.js`) that integrates Bootstrap 5 **Modals** and **Offcanvases** with HTMX swap events, so they open and close automatically based on HTMX responses.
|
|
284
|
+
|
|
285
|
+
#### How it works
|
|
286
|
+
|
|
287
|
+
| Behaviour | Trigger |
|
|
288
|
+
|---|---|
|
|
289
|
+
| Show a Modal or Offcanvas | HTMX swaps content into the element → `htmx:afterSwap` fires and calls `.show()`. |
|
|
290
|
+
| Hide a Modal or Offcanvas | HTMX receives an **empty** response targeting the element → `htmx:beforeSwap` fires, calls `.hide()`, and cancels the swap. |
|
|
291
|
+
| Reset Modal body | Bootstrap's `hidden.bs.modal` event fires → the modal body is cleared to `""`. |
|
|
292
|
+
|
|
293
|
+
#### Setup
|
|
294
|
+
|
|
295
|
+
Mark your Bootstrap Modal root elements with `data-htmx-plus-modal="<id>"` and your Offcanvas root elements with `data-htmx-plus-offcanvas="<id>"`, where id is the id of the element that will be swapped,:
|
|
296
|
+
|
|
297
|
+
```html
|
|
298
|
+
<!-- Bootstrap Modal managed by django-htmx-plus -->
|
|
299
|
+
<div class="modal fade" data-htmx-plus-modal="dialog">
|
|
300
|
+
<div id="dialog" class="modal-dialog">
|
|
301
|
+
<!-- Modal content will be swapped here by HTMX and shown/hidden by django-htmx-plus.js -->
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<!-- Bootstrap Offcanvas managed by django-htmx-plus -->
|
|
306
|
+
<div id="flyout" class="offcanvas offcanvas-end" data-htmx-plus-offcanvas="flyout">
|
|
307
|
+
<!-- Offcanvas content will be swapped here by HTMX and shown/hidden by django-htmx-plus.js -->
|
|
308
|
+
</div>
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Then point an HTMX element at the matching target ID:
|
|
312
|
+
|
|
313
|
+
```html
|
|
314
|
+
<button hx-get="/person/add/" hx-target="#dialog">
|
|
315
|
+
Add Person
|
|
316
|
+
</button>
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
- When the response has content the modal/offcanvas is shown automatically.
|
|
320
|
+
- When the server returns an empty `200` (or you use `HtmxResponse`) the modal/offcanvas is hidden automatically.
|
|
321
|
+
|
|
322
|
+
#### Including the script
|
|
323
|
+
|
|
324
|
+
Use the `{% htmx_plus_script %}` template tag to render the `<script>` tag. The script is loaded as an ES module and optionally forwards a CSP nonce if one is present in the template context:
|
|
325
|
+
|
|
326
|
+
```html
|
|
327
|
+
{% load django_htmx_plus %}
|
|
328
|
+
|
|
329
|
+
<!-- Place near the bottom of your base template, after Bootstrap JS -->
|
|
330
|
+
{% htmx_plus_script %}
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
This renders:
|
|
334
|
+
|
|
335
|
+
```html
|
|
336
|
+
<script src="/static/django_htmx_plus/django-htmx-plus.js" type="module"></script>
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
If a `nonce` variable is present in the template context it is automatically added as a `nonce="..."` attribute.
|
|
340
|
+
|
|
341
|
+
> **Note:** The script imports `Modal` and `Offcanvas` from `bootstrap`, so Bootstrap 5 must be available as an ES module (e.g. via an import map or a bundler). If you load Bootstrap as a plain global script instead, adjust your bundler or import map accordingly.
|
|
342
|
+
|
|
343
|
+
For example
|
|
344
|
+
```html
|
|
345
|
+
<script type="importmap">
|
|
346
|
+
{
|
|
347
|
+
"imports": {
|
|
348
|
+
"@popperjs/core": "{% static '@popperjs/core/dist/esm/index.js' %}",
|
|
349
|
+
"bootstrap": "{% static 'bootstrap/js/index.esm.js' %}",
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
</script>
|
|
353
|
+
```
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## License
|
|
357
|
+
|
|
358
|
+
MIT — see [LICENSE](LICENSE) for details.
|
|
359
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
django_htmx_plus/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
django_htmx_plus/http.py,sha256=lzZ2PVE9k2qZtbuDZcPyeG8aLCqYmcb5QgNS6QhrHZo,1906
|
|
3
|
+
django_htmx_plus/middleware.py,sha256=hF9stzCDm5OoptufbWI_HivVKBNZZScriYO5VySkr0s,2487
|
|
4
|
+
django_htmx_plus/mixins.py,sha256=ZT4C8CSj7uo91JXkYeyTriK_sV_bZrUrPFV1IZucgNU,1558
|
|
5
|
+
django_htmx_plus/static/django_htmx_plus/django-htmx-plus.js,sha256=ZOJzI2dhY9O-2QQ4PC6_MAB1KHg7poBOuTsPBXSA3cI,1084
|
|
6
|
+
django_htmx_plus/templates/cotton/icons/chevron_down.html,sha256=Z7dk_8WlPfD9yM0tvOmeLqdz40bH2FKOijR1ut78IYM,371
|
|
7
|
+
django_htmx_plus/templates/cotton/icons/chevron_left.html,sha256=cYTKu2DXJr51fKmlsNJ8egGZe6fcGIx0GfVfxDX6bsE,372
|
|
8
|
+
django_htmx_plus/templates/cotton/icons/chevron_right.html,sha256=JFiqUX9HNULUPnIU1Qsy7BehEK_zKMUgM-oGCZpLSuA,372
|
|
9
|
+
django_htmx_plus/templates/cotton/icons/chevron_up.html,sha256=sX-PrHuOqdPpOc5Sd8b_nZyR929hbpD7wvj0XA2l5jQ,370
|
|
10
|
+
django_htmx_plus/templates/cotton/tables/head.html,sha256=Nc3FGv8oW5Z3vDKhrI0J2CL0Q9WiY8O-sA7DRygKA2U,42
|
|
11
|
+
django_htmx_plus/templates/cotton/tables/header_cell.html,sha256=oOn4eGgzFjUrLuP7e75tqG9nYVMkM9g2h5MnBfLy4lw,596
|
|
12
|
+
django_htmx_plus/templates/cotton/tables/htmx_table.html,sha256=QA06V6GoleCErxnsLdRA5NERL2Oh5ZjrJ3TtuWHrWjM,563
|
|
13
|
+
django_htmx_plus/templates/cotton/tables/pager.html,sha256=FVr32_hkGlld7mEVouhc__cYW-UJrLaGybCH4YqW_Vw,1542
|
|
14
|
+
django_htmx_plus/templatetags/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
django_htmx_plus/templatetags/cotton_extras.py,sha256=J9WN6DYU6OUDFUixkcPsbS7FjR1F4BXpjO6jKFkJ0mU,364
|
|
16
|
+
django_htmx_plus/templatetags/django_htmx_plus.py,sha256=Jiif_JRJ0uUm6en04CVAWRWKY568L2WzeprH7jGRMGo,488
|
|
17
|
+
django_htmx_plus/types.py,sha256=H62ZyhUPHXB4bvHTOPYxtH9woiPgrL5pYzJqwCpAKXs,369
|
|
18
|
+
django_htmx_plus/utils.py,sha256=5lTLKdzyHAI4IBMz9SnKbWIhC1kvS72Ty0haI9KpQ5M,4597
|
|
19
|
+
django_htmx_plus/views.py,sha256=iKSnI9pcVxPmZz5blBg5myc6Vm5MJNKKlEJMbV0n15Q,6846
|
|
20
|
+
django_htmx_plus-0.0.0.dist-info/METADATA,sha256=EIriN9Y9RlyAmaeJuMzWPdR__XMGOA6uiFksPxVF0bo,12326
|
|
21
|
+
django_htmx_plus-0.0.0.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
22
|
+
django_htmx_plus-0.0.0.dist-info/entry_points.txt,sha256=ewyTbgB2PkL7jOPQVOJII2vXE-_GLEvIN3-TLnfpuoE,67
|
|
23
|
+
django_htmx_plus-0.0.0.dist-info/licenses/LICENSE.md,sha256=katsVWhESJx9CgbnTX1Cw3ZNNxgIWM58vSrx6rUqluE,1065
|
|
24
|
+
django_htmx_plus-0.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tim Davis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|