mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 18:51:09 -05:00
529 lines
11 KiB
Markdown
529 lines
11 KiB
Markdown
# 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:
|
|
|
|
```html
|
|
<!-- 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:
|
|
|
|
```html
|
|
<!-- 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:
|
|
|
|
```html
|
|
<!-- 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:
|
|
|
|
```html
|
|
<!-- 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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
# 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:
|
|
|
|
```python
|
|
response['HX-Redirect'] = '/parks/'
|
|
```
|
|
|
|
### HX-Refresh
|
|
|
|
Trigger a full page refresh:
|
|
|
|
```python
|
|
response['HX-Refresh'] = 'true'
|
|
```
|
|
|
|
### HX-Retarget / HX-Reswap
|
|
|
|
Override target and swap method:
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```python
|
|
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
|
|
|
|
```html
|
|
<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:
|
|
|
|
```css
|
|
/* 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:
|
|
|
|
```html
|
|
<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
|
|
|
|
```html
|
|
<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
|
|
|
|
```html
|
|
<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
|
|
|
|
```python
|
|
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
|
|
|
|
```html
|
|
<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
|
|
|
|
```html
|
|
{% 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
|
|
|
|
```html
|
|
{% 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
|
|
|
|
```html
|
|
<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
|
|
|
|
```python
|
|
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
|
|
|
|
```html
|
|
<!-- 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
|
|
|
|
```python
|
|
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
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```python
|
|
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
|
|
|
|
```html
|
|
<!-- Good -->
|
|
<button hx-delete="/parks/123/">Delete</button>
|
|
|
|
<!-- Avoid -->
|
|
<button hx-post="/parks/123/delete/">Delete</button>
|
|
```
|
|
|
|
### 2. Include CSRF Token
|
|
|
|
```html
|
|
<body hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'>
|
|
```
|
|
|
|
### 3. Use hx-confirm for Destructive Actions
|
|
|
|
```html
|
|
<button hx-delete="/parks/123/"
|
|
hx-confirm="Are you sure you want to delete this park?">
|
|
Delete
|
|
</button>
|
|
```
|
|
|
|
### 4. Preserve Scroll Position
|
|
|
|
```html
|
|
<div hx-get="/parks/"
|
|
hx-swap="innerHTML show:top">
|
|
```
|
|
|
|
### 5. Push URL for Navigation
|
|
|
|
```html
|
|
<a hx-get="/parks/123/"
|
|
hx-target="#main"
|
|
hx-push-url="true">
|
|
View Park
|
|
</a>
|
|
```
|
|
|
|
### 6. Handle Browser Back Button
|
|
|
|
```javascript
|
|
window.addEventListener('popstate', (event) => {
|
|
if (event.state && event.state.htmx) {
|
|
htmx.ajax('GET', window.location.href, '#main');
|
|
}
|
|
});
|
|
```
|