mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 10:11:09 -05:00
11 KiB
11 KiB
HTMX Conventions
This document outlines the standardized HTMX patterns and conventions used in ThrillWiki.
Request Attributes
hx-get / hx-post / hx-put / hx-delete
Use the appropriate HTTP method for the action:
<!-- Read operations -->
<button hx-get="/parks/">Load Parks</button>
<!-- Create operations -->
<form hx-post="/parks/create/">...</form>
<!-- Update operations -->
<form hx-put="/parks/{{ park.id }}/edit/">...</form>
<!-- Delete operations -->
<button hx-delete="/parks/{{ park.id }}/">Delete</button>
hx-target
Specify where the response should be inserted:
<!-- Target by ID -->
<button hx-get="/content/" hx-target="#content-area">Load</button>
<!-- Target closest ancestor -->
<button hx-delete="/item/" hx-target="closest .item-row">Delete</button>
<!-- Target this element -->
<div hx-get="/refresh/" hx-target="this">Content</div>
hx-swap
Control how content is inserted:
<!-- Replace inner HTML (default) -->
<div hx-get="/content/" hx-swap="innerHTML">...</div>
<!-- Replace entire element -->
<div hx-get="/content/" hx-swap="outerHTML">...</div>
<!-- Insert before/after -->
<div hx-get="/content/" hx-swap="beforeend">...</div>
<div hx-get="/content/" hx-swap="afterbegin">...</div>
<!-- Delete element -->
<button hx-delete="/item/" hx-swap="delete">Remove</button>
<!-- No swap (for side effects only) -->
<button hx-post="/action/" hx-swap="none">Action</button>
hx-trigger
Define when requests are made:
<!-- On click (default for buttons) -->
<button hx-get="/content/" hx-trigger="click">Load</button>
<!-- On page load -->
<div hx-get="/content/" hx-trigger="load">...</div>
<!-- When element becomes visible -->
<div hx-get="/more/" hx-trigger="revealed">Load More</div>
<!-- On form input with debounce -->
<input hx-get="/search/" hx-trigger="keyup changed delay:300ms">
<!-- On blur for validation -->
<input hx-post="/validate/" hx-trigger="blur changed">
<!-- Custom event -->
<div hx-get="/refresh/" hx-trigger="refresh from:body">...</div>
Response Headers
Server-Side Response Helpers
Use the standardized HTMX utility functions in views:
from apps.core.htmx_utils import (
htmx_success,
htmx_error,
htmx_redirect,
htmx_modal_close,
htmx_refresh,
)
def create_park(request):
# On success
return htmx_success('Park created successfully!')
# On error
return htmx_error('Validation failed', status=422)
# Redirect
return htmx_redirect('/parks/')
# Close modal with refresh
return htmx_modal_close(
message='Park saved!',
refresh_target='#parks-list'
)
HX-Trigger
Trigger client-side events from the server:
# Single event
response['HX-Trigger'] = 'parkCreated'
# Event with data
response['HX-Trigger'] = json.dumps({
'showToast': {
'type': 'success',
'message': 'Park created!'
}
})
# Multiple events
response['HX-Trigger'] = json.dumps({
'closeModal': True,
'refreshList': {'target': '#parks-list'},
'showToast': {'type': 'success', 'message': 'Done!'}
})
HX-Redirect
Perform client-side navigation:
response['HX-Redirect'] = '/parks/'
HX-Refresh
Trigger a full page refresh:
response['HX-Refresh'] = 'true'
HX-Retarget / HX-Reswap
Override target and swap method:
response['HX-Retarget'] = '#different-target'
response['HX-Reswap'] = 'outerHTML'
Partial Templates
Convention
For each full template, create a corresponding partial:
templates/
parks/
list.html # Full page
list_partial.html # Just the content (for HTMX)
detail.html
detail_partial.html
Usage with Decorator
from apps.core.htmx_utils import htmx_partial
@htmx_partial('parks/list.html')
def park_list(request):
parks = Park.objects.all()
return ({'parks': parks},) # Returns context tuple
Manual Detection
from apps.core.htmx_utils import is_htmx_request
def park_list(request):
parks = Park.objects.all()
if is_htmx_request(request):
return render(request, 'parks/list_partial.html', {'parks': parks})
return render(request, 'parks/list.html', {'parks': parks})
Loading States
Indicator Attribute
<button hx-get="/content/"
hx-indicator="#loading-spinner">
Load Content
</button>
<div id="loading-spinner" class="htmx-indicator">
<i class="fas fa-spinner fa-spin"></i>
</div>
CSS Classes
HTMX automatically adds classes during requests:
/* Added to element making request */
.htmx-request {
opacity: 0.5;
pointer-events: none;
}
/* Added to indicator elements */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline-block;
}
Skeleton Screens
Use skeleton components for better UX:
<div id="parks-list"
hx-get="/parks/"
hx-trigger="load"
hx-swap="innerHTML">
{% include 'components/skeletons/card_grid_skeleton.html' with cards=6 %}
</div>
Form Handling
Standard Form Pattern
<form id="park-form"
hx-post="{% url 'parks:create' %}"
hx-target="#park-form"
hx-swap="outerHTML"
hx-indicator="#form-loading">
{% csrf_token %}
{% include 'forms/partials/form_field.html' with field=form.name %}
{% include 'forms/partials/form_field.html' with field=form.location %}
<div id="form-loading" class="htmx-indicator">
Saving...
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
Inline Validation
<input type="text"
name="name"
hx-post="{% url 'parks:validate_name' %}"
hx-trigger="blur changed delay:500ms"
hx-target="#name-feedback"
hx-swap="innerHTML">
<div id="name-feedback"></div>
Server Response
from apps.core.htmx_utils import htmx_validation_response
def validate_name(request):
name = request.POST.get('name', '')
if not name:
return htmx_validation_response('name', errors=['Name is required'])
if Park.objects.filter(name=name).exists():
return htmx_validation_response('name', errors=['Name already exists'])
return htmx_validation_response('name', success_message='Name is available')
Pagination
Load More Pattern
<div id="parks-list">
{% for park in parks %}
{% include 'parks/_card.html' %}
{% endfor %}
</div>
{% if page_obj.has_next %}
<button hx-get="?page={{ page_obj.next_page_number }}"
hx-target="#parks-list"
hx-swap="beforeend"
hx-select="#parks-list > *"
class="btn btn-outline">
Load More
</button>
{% endif %}
Infinite Scroll
{% if page_obj.has_next %}
<div hx-get="?page={{ page_obj.next_page_number }}"
hx-trigger="revealed"
hx-target="this"
hx-swap="outerHTML"
hx-select="oob-swap">
<div class="htmx-indicator text-center py-4">
<i class="fas fa-spinner fa-spin"></i> Loading...
</div>
</div>
{% endif %}
Traditional Pagination
{% include 'components/pagination.html' with
page_obj=page_obj
hx_target='#parks-list'
hx_swap='innerHTML'
hx_push_url='true'
%}
Modal Integration
Opening Modal with Content
<button hx-get="{% url 'parks:edit' park.id %}"
hx-target="#modal-content"
hx-swap="innerHTML"
@click="showEditModal = true">
Edit Park
</button>
<div x-show="showEditModal" @close-modal.window="showEditModal = false">
<div id="modal-content">
<!-- Content loaded here -->
</div>
</div>
Closing Modal from Server
def save_park(request, pk):
park = get_object_or_404(Park, pk=pk)
form = ParkForm(request.POST, instance=park)
if form.is_valid():
form.save()
return htmx_modal_close(
message='Park updated successfully!',
refresh_target='#park-detail'
)
return render(request, 'parks/_edit_form.html', {'form': form})
Out-of-Band Updates
Multiple Element Updates
<!-- Main response -->
<div id="main-content">
Updated content here
</div>
<!-- Out-of-band updates -->
<div id="notification-count" hx-swap-oob="true">
<span class="badge">5</span>
</div>
<div id="sidebar-status" hx-swap-oob="true">
<span class="status">Online</span>
</div>
Server-Side OOB
def update_item(request, pk):
item = get_object_or_404(Item, pk=pk)
# ... update logic ...
# Render multiple fragments
main_html = render_to_string('items/_detail.html', {'item': item})
count_html = render_to_string('items/_count.html', {'count': Item.objects.count()})
return HttpResponse(
main_html +
f'<div id="item-count" hx-swap-oob="true">{count_html}</div>'
)
Error Handling
HTTP Status Codes
HTMX handles different status codes:
- 2xx: Normal swap
- 204: No swap (success, no content)
- 4xx: Swap (show validation errors)
- 5xx: Trigger error event
Client-Side Error Handling
document.body.addEventListener('htmx:responseError', (event) => {
const status = event.detail.xhr.status;
if (status === 401) {
Alpine.store('toast').error('Please log in to continue');
window.location.href = '/login/';
} else if (status >= 500) {
Alpine.store('toast').error('Server error. Please try again.');
}
});
Validation Errors
Return form HTML with errors on 422:
def create_park(request):
form = ParkForm(request.POST)
if not form.is_valid():
return render(
request,
'parks/_form.html',
{'form': form},
status=422
)
park = form.save()
return htmx_success(f'{park.name} created!')
Best Practices
1. Use Semantic URLs
<!-- Good -->
<button hx-delete="/parks/123/">Delete</button>
<!-- Avoid -->
<button hx-post="/parks/123/delete/">Delete</button>
2. Include CSRF Token
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
3. Use hx-confirm for Destructive Actions
<button hx-delete="/parks/123/"
hx-confirm="Are you sure you want to delete this park?">
Delete
</button>
4. Preserve Scroll Position
<div hx-get="/parks/"
hx-swap="innerHTML show:top">
5. Push URL for Navigation
<a hx-get="/parks/123/"
hx-target="#main"
hx-push-url="true">
View Park
</a>
6. Handle Browser Back Button
window.addEventListener('popstate', (event) => {
if (event.state && event.state.htmx) {
htmx.ajax('GET', window.location.href, '#main');
}
});