=====================
HTMX Integration
=====================
.. contents:: Table of Contents
:local:
:depth: 3
Introduction
============
This Django project template provides a comprehensive HTMX integration that goes far beyond typical Django+HTMX setups. It includes custom view classes (``MainContentView``, ``HTMXView``), session mixins, out-of-band update patterns, and a structured component system that enables building highly interactive, server-rendered applications without writing JavaScript.
Why HTMX?
---------
HTMX embraces the hypermedia approach to building web applications, where the server returns HTML instead of JSON. This approach offers several advantages:
* **Reduced Complexity**: No need to maintain separate frontend/backend codebases or manage state synchronization
* **Better Performance**: Smaller payload sizes compared to full-page reloads or heavy JavaScript frameworks
* **Progressive Enhancement**: Applications work without JavaScript, then enhance with HTMX
* **Developer Productivity**: Backend developers can build interactive UIs without deep frontend expertise
* **SEO-Friendly**: Server-rendered HTML is naturally crawlable by search engines
This Project's Approach
-----------------------
This template provides a sophisticated HTMX integration with:
* **Custom View Classes**: ``MainContentView`` and ``HTMXView`` that handle common patterns
* **Out-of-Band Updates**: Update multiple page sections in a single response
* **Session Mixins**: Automatic team and user context management
* **Component System**: Reusable, composable UI components
* **Modal System**: Dynamic modal dialogs with form handling
* **URL History Management**: SPA-like navigation with proper browser history
When to Use HTMX vs. Traditional Views
---------------------------------------
Use ``MainContentView`` for:
* Standard full-page loads
* Pages with no dynamic updates
* Initial page renders that may have HTMX components within them
Use ``HTMXView`` for:
* Dynamic component updates
* Form submissions with partial page updates
* Loading content on-demand (tabs, modals, drawers)
* Multi-region updates (navbar, content, sidebar)
Architecture Overview
=====================
View Class Hierarchy
--------------------
.. code-block:: text
View (Django base)
└── MainContentView
├── Handles full page and partial rendering
├── Automatic base template selection
└── Context management
└── HTMXView
├── HTMX-only enforcement
├── Out-of-band template rendering
├── URL history management
└── Toast message handling
Session Mixins
--------------
Mixins can be combined with view classes using multiple inheritance:
.. code-block:: python
class TeamDashboard(TeamSessionMixin, HTMXView):
# Has access to self.team automatically
pass
Available Mixins:
* ``SessionStateMixin``: Base session state management
* ``TeamSessionMixin``: Team context loading and access control
View Classes Reference
======================
MainContentView
---------------
Base view class for standard page rendering with HTMX support.
**Purpose**: Provides a consistent foundation for all views in the project, handling template selection based on request type (full page vs. HTMX partial).
**Location**: ``apps.public.views.helpers.main_content_view``
Key Attributes
^^^^^^^^^^^^^^
.. py:attribute:: template_name
:type: str | None
The template to render. Must be set in subclasses or passed to ``render()``.
.. py:attribute:: base_template
:type: str
Default: ``"base.html"``
The base template for full page loads. Includes HTML structure, head, body, navigation, and footer.
.. py:attribute:: partial_template
:type: str
Default: ``"partial.html"``
The base template for HTMX partial updates. Contains only content blocks without page structure.
.. py:attribute:: url
:type: str
Default: ``""``
URL associated with this view for history management.
.. py:attribute:: context
:type: dict
Initialized automatically. Contains template context variables.
Key Methods
^^^^^^^^^^^
.. py:method:: dispatch(request, *args, **kwargs)
Automatically selects the appropriate base template based on whether the request is from HTMX.
**Process**:
1. Checks if request has HTMX headers
2. Sets ``context['base_template']`` to ``partial_template`` for HTMX, ``base_template`` otherwise
3. Adds ``url`` and ``just_logged_in`` to context
4. Calls parent dispatch
.. py:method:: render(request=None, template_name=None, context=None)
Renders the template with the provided or default context.
:param request: Django HttpRequest object (defaults to ``self.request``)
:param template_name: Template path (defaults to ``self.template_name``)
:param context: Additional context to merge (defaults to ``{}``)
:return: HttpResponse with rendered template
**Example**:
.. code-block:: python
def get(self, request, *args, **kwargs):
self.context['items'] = Item.objects.all()
return self.render(request)
Usage Example
^^^^^^^^^^^^^
**Full Page View**:
.. code-block:: python
from apps.public.views.helpers.main_content_view import MainContentView
class HomePage(MainContentView):
template_name = "pages/home.html"
url = "/"
def get(self, request, *args, **kwargs):
self.context.update({
'featured_posts': BlogPost.objects.filter(featured=True)[:3],
'stats': get_site_stats(),
})
return self.render(request)
**Template** (``pages/home.html``):
.. code-block:: django
{% extends base_template %}
{% block content %}
Welcome to Our Site
{% for post in featured_posts %}
{% include "components/cards/card_blog_post.html" %}
{% endfor %}
{% endblock %}
HTMXView
--------
Specialized view for handling HTMX requests with advanced features like out-of-band updates, URL history management, and multiple template rendering.
**Purpose**: Provides a powerful foundation for building interactive components that update multiple page regions in a single request.
**Location**: ``apps.public.views.helpers.htmx_view``
**Inherits From**: ``MainContentView``
Key Attributes
^^^^^^^^^^^^^^
.. py:attribute:: template_name
:type: str | None
The main template to render in the response.
.. py:attribute:: oob_templates
:type: dict[str, str] | None
Dictionary mapping target element IDs to template paths for out-of-band updates.
**Example**: ``{"sidebar": "components/sidebar.html", "toast-container": "layout/messages/toast.html"}``
.. py:attribute:: push_url
:type: str | None
URL to push to browser history. Enables SPA-like navigation.
.. py:attribute:: has_oob
:type: bool
Default: ``True``
Whether to include automatic OOB updates (toasts, navigation state).
.. py:attribute:: active_nav
:type: str | None
Active navigation section identifier (e.g., 'home', 'teams', 'todos'). Sets the active state in navigation.
.. py:attribute:: show_toast
:type: bool
Default: ``True``
Whether to automatically include toast messages in OOB updates.
.. py:attribute:: include_modals
:type: bool
Default: ``False``
Whether to include modal container in OOB updates.
Key Methods
^^^^^^^^^^^
.. py:method:: dispatch(request, *args, **kwargs)
Enforces HTMX-only access and ensures partial template is used.
:raises NotImplementedError: If request is not from HTMX
**Security Note**: This prevents direct browser access to component endpoints.
.. py:method:: render(request=None, template_name=None, context=None, oob_templates=None, push_url=None)
Renders the main template with optional OOB templates in a combined response.
:param request: Django HttpRequest object
:param template_name: Main template to render (defaults to class attribute)
:param context: Context data for templates
:param oob_templates: Dict of OOB templates (defaults to class attribute)
:param push_url: URL for history state (defaults to class attribute)
:return: HttpResponse with combined HTML and HTMX headers
**Process**:
1. Renders main template if provided
2. Adds standard OOB components if ``has_oob`` is True:
* Toast messages (if ``show_toast`` and messages exist)
* Navigation active state (if ``active_nav`` is set)
* Modal container (if ``include_modals`` is True)
3. Renders each OOB template with ``is_oob`` context flag
4. Wraps OOB content with ``hx-swap-oob="true"`` attributes
5. Combines all HTML
6. Adds URL history header if ``push_url`` is set
Usage Examples
^^^^^^^^^^^^^^
**Simple Component Update**:
.. code-block:: python
from apps.public.views.helpers.htmx_view import HTMXView
class TeamStatsComponent(HTMXView):
template_name = "components/team_stats.html"
def get(self, request, *args, **kwargs):
team_id = kwargs.get('team_id')
team = get_object_or_404(Team, id=team_id)
self.context.update({
'team': team,
'member_count': team.members.count(),
'active_projects': team.projects.filter(status='active').count(),
})
return self.render(request)
**With Out-of-Band Updates**:
.. code-block:: python
from django.contrib import messages
from apps.public.views.helpers.htmx_view import HTMXView
from apps.public.views.helpers.session_mixin import TeamSessionMixin
class AddTeamMemberView(TeamSessionMixin, HTMXView):
template_name = "components/lists/list_team_members.html"
oob_templates = {
"team-stats": "components/team_stats.html",
}
has_oob = True # Enables automatic toast messages
show_toast = True
def post(self, request, *args, **kwargs):
form = AddMemberForm(request.POST)
if form.is_valid():
member = form.save(commit=False)
member.team = self.team
member.save()
messages.success(request, f"{member.user.name} added to team!")
# Update context for both templates
self.context.update({
'members': self.team.members.all(),
'member_count': self.team.members.count(),
'team': self.team,
})
return self.render(request)
# Show validation errors
self.context['form'] = form
return self.render(request, template_name="components/forms/add_member_form.html")
**With URL History Management**:
.. code-block:: python
class TeamDetailComponent(TeamSessionMixin, HTMXView):
template_name = "components/team_detail.html"
active_nav = "teams"
def get(self, request, *args, **kwargs):
self.context['team'] = self.team
# Update browser URL
return self.render(
request,
push_url=f"/teams/{self.team.slug}/"
)
OOB Template Example
^^^^^^^^^^^^^^^^^^^^
When ``is_oob`` is True in the context, templates can include their target ID:
.. code-block:: django
{# components/team_stats.html #}
{% if is_oob %}
{% else %}
{% endif %}
Members{{ member_count }}
Projects{{ active_projects }}
Session Mixins
--------------
SessionStateMixin
^^^^^^^^^^^^^^^^^
**Purpose**: Base mixin for managing user session state.
**Location**: ``apps.public.views.helpers.session_mixin``
**Features**:
* Tracks login state with ``just_logged_in`` flag
* Provides ``handle_unauthenticated()`` method for redirecting to login
* Initializes context dictionary
**Usage**:
.. code-block:: python
from apps.public.views.helpers.session_mixin import SessionStateMixin
from apps.public.views.helpers.main_content_view import MainContentView
class UserDashboard(SessionStateMixin, MainContentView):
template_name = "dashboard/home.html"
def get(self, request, *args, **kwargs):
# Access just_logged_in from context
if self.context.get('just_logged_in'):
messages.info(request, "Welcome back!")
return self.render(request)
TeamSessionMixin
^^^^^^^^^^^^^^^^
**Purpose**: Mixin for views that require team context. Automatically loads and validates team access.
**Location**: ``apps.public.views.helpers.session_mixin``
**Inherits From**: ``SessionStateMixin``
**Key Attributes**:
.. py:attribute:: team
:type: Team | None
The current team object, automatically loaded from URL kwargs or session.
.. py:attribute:: require_team
:type: bool
Default: ``True``
Whether to require the user to be a member of a team. If ``True`` and user has no teams, redirects to team creation.
**Process Flow**:
1. In ``setup()``:
* Attempts to load team from URL kwargs (``team_id``) or session
* Validates user is a member of the team
* Falls back to user's first team if none specified
* Stores team in session and adds to context
2. In ``dispatch()``:
* Checks authentication
* If ``require_team`` is ``True`` and user has no teams, redirects to team creation
* Calls parent dispatch
**Methods**:
.. py:method:: handle_no_team(request)
Redirects users without teams to the team creation view with an info message.
**Usage Example**:
.. code-block:: python
from apps.public.views.helpers.session_mixin import TeamSessionMixin
from apps.public.views.helpers.htmx_view import HTMXView
class TeamSettingsView(TeamSessionMixin, HTMXView):
template_name = "components/team_settings.html"
require_team = True
def get(self, request, *args, **kwargs):
# self.team is automatically available
self.context.update({
'settings': self.team.settings,
'can_manage': self.team.user_can_manage(request.user),
})
return self.render(request)
def post(self, request, *args, **kwargs):
if not self.team.user_can_manage(request.user):
messages.error(request, "You don't have permission to edit settings.")
return self.render(request)
# Update settings
form = TeamSettingsForm(request.POST, instance=self.team)
if form.is_valid():
form.save()
messages.success(request, "Settings updated!")
return self.render(request)
**Multiple Mixin Example**:
.. code-block:: python
class TeamDashboard(TeamSessionMixin, MainContentView):
"""
Full page dashboard for team overview.
Uses MainContentView for full page rendering.
"""
template_name = "teams/dashboard.html"
require_team = True
class TeamStatsComponent(TeamSessionMixin, HTMXView):
"""
HTMX component for team statistics.
Uses HTMXView for partial updates.
"""
template_name = "components/team_stats.html"
require_team = True
Template Organization
=====================
Directory Structure
-------------------
The project organizes templates into a clear, component-based structure:
.. code-block:: text
templates/
├── base.html # Main base template (full page)
├── partial.html # Base template for HTMX partials
│
├── pages/ # Full page templates
│ ├── home.html
│ ├── about.html
│ └── pricing.html
│
├── components/ # Reusable UI components
│ ├── _component_base.html # Base template for components
│ │
│ ├── forms/ # Form components
│ │ ├── text_input.html
│ │ ├── textarea.html
│ │ ├── select.html
│ │ ├── checkbox.html
│ │ └── form_user.html
│ │
│ ├── lists/ # List components
│ │ └── list_team_members.html
│ │
│ ├── cards/ # Card components
│ │ ├── card_team.html
│ │ └── card_blog_post.html
│ │
│ ├── modals/ # Modal dialogs
│ │ ├── modal_base.html
│ │ ├── modal_confirm.html
│ │ ├── modal_content.html
│ │ ├── modal_dialog.html
│ │ └── modal_form.html
│ │
│ └── common/ # Common UI components
│ ├── notification_toast.html
│ ├── status_badge.html
│ └── error_message.html
│
├── layout/ # Page layout elements
│ ├── footer.html
│ ├── modals.html # Modal containers
│ │
│ ├── messages/ # Notification templates
│ │ └── toast.html
│ │
│ ├── alerts/ # Alert templates
│ │ └── alert.html
│ │
│ ├── nav/ # Navigation components
│ │ ├── navbar.html
│ │ ├── account_menu.html
│ │ ├── active_nav.html
│ │ └── search.html
│ │
│ └── modals/
│ └── modal_container.html
│
├── teams/ # Team-specific templates
│ ├── team_list.html
│ ├── team_detail.html
│ ├── team_form.html
│ └── team_confirm_delete.html
│
└── account/ # Account templates
├── login.html
├── settings.html
└── password/
├── change.html
└── reset.html
Naming Conventions
------------------
Component templates follow these patterns:
* **Type Prefix**: ``{type}_{name}.html`` (e.g., ``form_user.html``, ``list_team_members.html``, ``card_team.html``)
* **Base Templates**: Prefix with underscore (e.g., ``_component_base.html``)
* **Descriptive Names**: Clear indication of purpose (e.g., ``modal_confirm.html``, ``notification_toast.html``)
Base Templates
--------------
base.html
^^^^^^^^^
The main layout template providing full HTML structure for initial page loads.
**Key Features**:
* Complete HTML document structure (````, ````, ````)
* HTMX and Alpine.js library imports
* Navigation bar
* Flash message container
* Modal containers
* Footer
* Extensive block system for customization
**Important Blocks**:
.. code-block:: django
{% block title %}Django Project Template{% endblock %}
{% block meta %}{% endblock %}
{% block css %}{% endblock %}
{% block header %}{% endblock %}
{% block messages %}{% endblock %}
{% block main_header %}{% endblock %}
{% block content %}{% endblock %}
{% block aside %}{% endblock %}
{% block main_footer %}{% endblock %}
{% block footer %}{% endblock %}
{% block scripts %}{% endblock %}
partial.html
^^^^^^^^^^^^
Base template for HTMX partial updates. Contains no HTML structure, only content blocks.
**Features**:
* No page structure elements
* Single content block
* Support for HTMX trigger events
* Minimal overhead for partial updates
**Usage**:
.. code-block:: django
{% extends "partial.html" %}
{% block content %}
{% for team in teams %}
{% include "components/cards/card_team.html" %}
{% endfor %}
{% endblock %}
_component_base.html
^^^^^^^^^^^^^^^^^^^^
Base template for all reusable components. Extends ``partial.html``.
**Usage**:
.. code-block:: django
{% extends "components/_component_base.html" %}
{% block content %}
{% endblock %}
Building Components
-------------------
Step-by-Step Component Creation
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
**1. Choose Component Type and Location**
Determine the component category and create in appropriate directory:
* ``components/forms/`` for form inputs
* ``components/lists/`` for item lists
* ``components/cards/`` for data display cards
* ``components/modals/`` for modal dialogs
* ``components/common/`` for general UI elements
**2. Extend Component Base**
.. code-block:: django
{% extends "components/_component_base.html" %}
{% block content %}
{% endblock %}
**3. Add OOB Support**
For components that can be updated out-of-band:
.. code-block:: django
{% if is_oob %}
{% else %}
{% endif %}
**4. Document Context Requirements**
Add a comment block describing required context variables:
.. code-block:: django
{% comment %}
Component: Team Member Card
Required Context:
- member: TeamMember object
- can_manage: boolean, whether user can edit/remove member
Optional Context:
- show_role: boolean, whether to display role badge (default: True)
Usage:
{% include "components/cards/card_team_member.html" with member=member can_manage=True %}
{% endcomment %}
**5. Make Components Flexible**
Use context defaults and conditionals:
.. code-block:: django
{% load static %}
{{ member.user.name }}
{{ member.user.email }}
{% if show_role|default:True %}
{{ member.get_role_display }}
{% endif %}
{% if can_manage %}
{% endif %}
Component Examples
------------------
Form Component
^^^^^^^^^^^^^^
A reusable form component with validation support:
**View** (``apps/public/views/teams/member_views.py``):
.. code-block:: python
from django import forms
from django.contrib import messages
from apps.public.views.helpers.htmx_view import HTMXView
from apps.public.views.helpers.session_mixin import TeamSessionMixin
from apps.common.models.team import TeamMember, Role
class AddMemberForm(forms.Form):
email = forms.EmailField(
label="Email Address",
widget=forms.EmailInput(attrs={
'class': 'form-input',
'placeholder': 'user@example.com'
})
)
role = forms.ChoiceField(
choices=[(r.value, r.label) for r in Role],
initial=Role.MEMBER.value
)
class AddMemberFormView(TeamSessionMixin, HTMXView):
template_name = "components/forms/form_add_member.html"
def get(self, request, *args, **kwargs):
self.context['form'] = AddMemberForm()
return self.render(request)
def post(self, request, *args, **kwargs):
form = AddMemberForm(request.POST)
if form.is_valid():
email = form.cleaned_data['email']
role = form.cleaned_data['role']
# Add member logic
try:
user = User.objects.get(email=email)
TeamMember.objects.create(
team=self.team,
user=user,
role=role
)
messages.success(request, f"{user.name} added to team!")
# Return updated member list
self.context['members'] = self.team.members.all()
return self.render(
request,
template_name="components/lists/list_team_members.html"
)
except User.DoesNotExist:
form.add_error('email', 'User not found')
self.context['form'] = form
return self.render(request)
**Template** (``components/forms/form_add_member.html``):
.. code-block:: django
{% extends "components/_component_base.html" %}
{% block content %}
{% endblock %}
List Component
^^^^^^^^^^^^^^
A list component with add/remove operations:
**View** (``apps/public/views/teams/member_views.py``):
.. code-block:: python
class TeamMemberListView(TeamSessionMixin, HTMXView):
template_name = "components/lists/list_team_members.html"
def get(self, request, *args, **kwargs):
self.context.update({
'members': self.team.members.select_related('user').all(),
'can_manage': self.team.user_can_manage(request.user),
})
return self.render(request)
class RemoveMemberView(TeamSessionMixin, HTMXView):
def delete(self, request, *args, **kwargs):
member_id = kwargs.get('member_id')
member = get_object_or_404(TeamMember, id=member_id, team=self.team)
if not self.team.user_can_manage(request.user):
return HttpResponse("Unauthorized", status=403)
member_name = member.user.name
member.delete()
messages.success(request, f"{member_name} removed from team")
# Return updated list
self.context.update({
'members': self.team.members.all(),
'can_manage': True,
})
return self.render(
request,
template_name="components/lists/list_team_members.html"
)
**Template** (``components/lists/list_team_members.html``):
.. code-block:: django
{% extends "components/_component_base.html" %}
{% block content %}
Team Members ({{ members|length }})
{% if can_manage %}
{% endif %}
{% for member in members %}
{% if member.user.avatar %}
{% else %}
{{ member.user.name|first|upper }}
{% endif %}
{{ member.user.name }}
{{ member.user.email }}
{{ member.get_role_display }}
{% if can_manage and member.role != 'owner' %}
{% endif %}
{% empty %}
No team members yet.
{% if can_manage %}
{% endif %}
{% endfor %}
{% endblock %}
Modal Component
^^^^^^^^^^^^^^^
A confirmation modal component:
**View** (``apps/public/views/teams/team_views.py``):
.. code-block:: python
class DeleteTeamConfirmView(TeamSessionMixin, HTMXView):
template_name = "components/modals/modal_confirm.html"
def get(self, request, *args, **kwargs):
if not self.team.user_can_delete(request.user):
messages.error(request, "You don't have permission to delete this team")
return HttpResponse("", status=403)
self.context.update({
'modal_title': 'Delete Team',
'modal_message': f'Are you sure you want to delete "{self.team.name}"? This action cannot be undone.',
'confirm_url': reverse('team:delete', kwargs={'team_id': self.team.id}),
'confirm_text': 'Delete Team',
'confirm_class': 'btn-danger',
'cancel_text': 'Cancel',
})
return self.render(request)
class DeleteTeamView(TeamSessionMixin, HTMXView):
def delete(self, request, *args, **kwargs):
if not self.team.user_can_delete(request.user):
return HttpResponse("Unauthorized", status=403)
team_name = self.team.name
self.team.delete()
messages.success(request, f'Team "{team_name}" has been deleted')
# Redirect to team list
from django_htmx import http as htmx
response = HttpResponse("")
return htmx.redirect(response, reverse('team:list'))
**Template** (``components/modals/modal_confirm.html``):
.. code-block:: django
{% extends "components/modals/modal_base.html" %}
{% block modal_content %}
{% endblock %}
Forms and Validation
====================
Form Handling Patterns
-----------------------
Inline Validation
^^^^^^^^^^^^^^^^^
Validate individual fields as the user types:
**View**:
.. code-block:: python
class ValidateEmailView(HTMXView):
def post(self, request, *args, **kwargs):
email = request.POST.get('email', '')
# Validate email
errors = []
if not email:
errors.append("Email is required")
elif User.objects.filter(email=email).exists():
errors.append("Email already registered")
elif not '@' in email:
errors.append("Invalid email format")
if errors:
return HttpResponse(
f'
{errors[0]}
',
status=400
)
return HttpResponse(
'
Available
'
)
**Template**:
.. code-block:: django
Form Submission with Error Handling
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Handle validation errors and display them inline:
**View**:
.. code-block:: python
from django import forms
from django.contrib import messages
class TeamForm(forms.ModelForm):
class Meta:
model = Team
fields = ['name', 'description']
def clean_name(self):
name = self.cleaned_data.get('name')
if Team.objects.filter(name__iexact=name).exists():
raise forms.ValidationError("A team with this name already exists")
return name
class CreateTeamView(HTMXView):
template_name = "components/forms/form_team.html"
oob_templates = {
"team-list": "components/lists/list_teams.html"
}
def get(self, request, *args, **kwargs):
self.context['form'] = TeamForm()
return self.render(request)
def post(self, request, *args, **kwargs):
form = TeamForm(request.POST)
if form.is_valid():
team = form.save(commit=False)
team.save()
# Add creator as owner
TeamMember.objects.create(
team=team,
user=request.user,
role=Role.OWNER.value
)
messages.success(request, f'Team "{team.name}" created successfully!')
# Update both form area and team list
self.context['teams'] = request.user.teams.all()
return self.render(
request,
template_name="components/forms/form_team_success.html"
)
# Re-render form with errors
self.context['form'] = form
return self.render(request)
**Template** (``components/forms/form_team.html``):
.. code-block:: django
{% extends "components/_component_base.html" %}
{% block content %}
{% endblock %}
Multi-Step Forms
^^^^^^^^^^^^^^^^
Implement wizard-style forms with HTMX:
**View**:
.. code-block:: python
class TeamOnboardingWizard(HTMXView):
template_name = "components/forms/wizard_step.html"
def get(self, request, *args, **kwargs):
step = request.GET.get('step', '1')
if step == '1':
self.context.update({
'step': 1,
'step_title': 'Create Your Team',
'form': TeamBasicInfoForm(),
'next_url': '?step=2',
})
elif step == '2':
self.context.update({
'step': 2,
'step_title': 'Invite Team Members',
'form': TeamInviteForm(),
'next_url': '?step=3',
'prev_url': '?step=1',
})
elif step == '3':
self.context.update({
'step': 3,
'step_title': 'Customize Settings',
'form': TeamSettingsForm(),
'prev_url': '?step=2',
'is_final': True,
})
return self.render(request)
def post(self, request, *args, **kwargs):
step = request.GET.get('step', '1')
# Store data in session and progress to next step
if step == '1':
form = TeamBasicInfoForm(request.POST)
if form.is_valid():
request.session['team_data'] = form.cleaned_data
return self.get(request, step='2')
elif step == '2':
form = TeamInviteForm(request.POST)
if form.is_valid():
request.session['invite_data'] = form.cleaned_data
return self.get(request, step='3')
elif step == '3':
form = TeamSettingsForm(request.POST)
if form.is_valid():
# Create team with all collected data
team = self.create_team(
request.session.get('team_data'),
request.session.get('invite_data'),
form.cleaned_data
)
# Clear session data
request.session.pop('team_data', None)
request.session.pop('invite_data', None)
messages.success(request, "Team created successfully!")
from django_htmx import http as htmx
response = HttpResponse("")
return htmx.redirect(response, reverse('team:detail', kwargs={'team_id': team.id}))
# Re-render current step with errors
self.context['form'] = form
self.context['step'] = step
return self.render(request)
Dynamic Form Fields
^^^^^^^^^^^^^^^^^^^
Add or remove form fields dynamically:
**View**:
.. code-block:: python
class AddFormFieldView(HTMXView):
template_name = "components/forms/field_email_invite.html"
def get(self, request, *args, **kwargs):
field_index = request.GET.get('index', '0')
self.context['field_index'] = field_index
return self.render(request)
**Template**:
.. code-block:: django
{# components/forms/form_invite_members.html #}
{# components/forms/field_email_invite.html #}
{% extends "partial.html" %}
{% block content %}
{% endblock %}
Out-of-Band Updates
===================
Understanding OOB Swaps
-----------------------
Out-of-band (OOB) updates allow HTMX to update multiple page elements in a single response. The response contains:
1. **Primary content**: Targets the element specified in ``hx-target``
2. **OOB content**: Updates other elements marked with ``hx-swap-oob="true"``
**Example Response**:
.. code-block:: html
Counter Updates
---------------
Update badge counts and notification indicators:
**View**:
.. code-block:: python
class MarkNotificationsRead(HTMXView):
oob_templates = {
"notification-badge": "components/notification_badge.html",
"notification-list": "components/notification_list.html",
}
def post(self, request, *args, **kwargs):
# Mark all as read
request.user.notifications.filter(read=False).update(read=True)
messages.success(request, "All notifications marked as read")
self.context.update({
'unread_count': 0,
'notifications': request.user.notifications.all()[:10],
})
return self.render(request)
**Badge Template** (``components/notification_badge.html``):
.. code-block:: django
{% if is_oob %}
{% else %}
{% endif %}
{% if unread_count > 0 %}
{{ unread_count }}
{% endif %}
Real-World Examples
===================
Example 1: Team Member Management
----------------------------------
A complete example showing full CRUD operations for team members.
**URLs** (``apps/public/urls/teams.py``):
.. code-block:: python
from django.urls import path
from apps.public.views.teams import member_views
app_name = 'team'
urlpatterns = [
# Team member list
path('members/', member_views.TeamMemberListView.as_view(), name='member-list'),
# Add member
path('members/add-form/', member_views.AddMemberFormView.as_view(), name='add-member-form'),
path('members/add/', member_views.AddMemberView.as_view(), name='add-member'),
# Edit member
path('members//edit/', member_views.EditMemberView.as_view(), name='edit-member'),
# Remove member
path('members//remove/', member_views.RemoveMemberView.as_view(), name='remove-member'),
]
**Views** (``apps/public/views/teams/member_views.py``):
.. code-block:: python
from django import forms
from django.contrib import messages
from django.shortcuts import get_object_or_404
from django.http import HttpResponse
from apps.public.views.helpers.htmx_view import HTMXView
from apps.public.views.helpers.session_mixin import TeamSessionMixin
from apps.common.models.team import TeamMember, Role
from apps.common.models.user import User
class TeamMemberListView(TeamSessionMixin, HTMXView):
"""Display list of team members."""
template_name = "components/lists/list_team_members.html"
def get(self, request, *args, **kwargs):
self.context.update({
'members': self.team.members.select_related('user').order_by('role', 'user__first_name'),
'can_manage': self.team.user_can_manage(request.user),
})
return self.render(request)
class AddMemberFormView(TeamSessionMixin, HTMXView):
"""Display form to add a new team member."""
template_name = "components/forms/form_add_member.html"
def get(self, request, *args, **kwargs):
if not self.team.user_can_manage(request.user):
return HttpResponse("Unauthorized", status=403)
self.context['form'] = AddMemberForm()
return self.render(request)
class AddMemberView(TeamSessionMixin, HTMXView):
"""Process adding a new team member."""
template_name = "components/lists/list_team_members.html"
oob_templates = {
"team-stats": "components/team_stats.html",
}
show_toast = True
def post(self, request, *args, **kwargs):
if not self.team.user_can_manage(request.user):
return HttpResponse("Unauthorized", status=403)
form = AddMemberForm(request.POST)
if form.is_valid():
email = form.cleaned_data['email']
role = form.cleaned_data['role']
try:
user = User.objects.get(email=email)
# Check if already a member
if TeamMember.objects.filter(team=self.team, user=user).exists():
messages.error(request, f"{user.name} is already a team member")
else:
TeamMember.objects.create(
team=self.team,
user=user,
role=role
)
messages.success(request, f"{user.name} added to team!")
# Close modal and update list
self.context.update({
'members': self.team.members.all(),
'can_manage': True,
'member_count': self.team.members.count(),
})
# Add script to close modal
response = self.render(request)
response.content = b'' + response.content
return response
except User.DoesNotExist:
form.add_error('email', 'No user found with this email address')
# Re-render form with errors
self.context['form'] = form
return self.render(request, template_name="components/forms/form_add_member.html")
class EditMemberView(TeamSessionMixin, HTMXView):
"""Edit team member role."""
template_name = "components/forms/form_edit_member.html"
def get(self, request, *args, **kwargs):
if not self.team.user_can_manage(request.user):
return HttpResponse("Unauthorized", status=403)
member_id = kwargs.get('member_id')
member = get_object_or_404(TeamMember, id=member_id, team=self.team)
self.context.update({
'member': member,
'form': EditMemberForm(instance=member),
})
return self.render(request)
def post(self, request, *args, **kwargs):
if not self.team.user_can_manage(request.user):
return HttpResponse("Unauthorized", status=403)
member_id = kwargs.get('member_id')
member = get_object_or_404(TeamMember, id=member_id, team=self.team)
form = EditMemberForm(request.POST, instance=member)
if form.is_valid():
form.save()
messages.success(request, f"Updated {member.user.name}'s role")
# Close modal and update list
self.context.update({
'members': self.team.members.all(),
'can_manage': True,
})
response = self.render(request, template_name="components/lists/list_team_members.html")
response.content = b'' + response.content
return response
self.context.update({
'member': member,
'form': form,
})
return self.render(request)
class RemoveMemberView(TeamSessionMixin, HTMXView):
"""Remove a team member."""
oob_templates = {
"team-stats": "components/team_stats.html",
}
show_toast = True
def delete(self, request, *args, **kwargs):
if not self.team.user_can_manage(request.user):
return HttpResponse("Unauthorized", status=403)
member_id = kwargs.get('member_id')
member = get_object_or_404(TeamMember, id=member_id, team=self.team)
# Prevent removing the last owner
if member.role == Role.OWNER.value:
owner_count = self.team.members.filter(role=Role.OWNER.value).count()
if owner_count <= 1:
messages.error(request, "Cannot remove the last owner")
return HttpResponse("", status=400)
member_name = member.user.name
member.delete()
messages.success(request, f"{member_name} removed from team")
# Return empty response with stats update
# The member item will be removed via hx-swap on the button
self.context.update({
'member_count': self.team.members.count(),
})
return self.render(request, template_name=None)
**Forms** (``apps/public/forms/team.py``):
.. code-block:: python
from django import forms
from apps.common.models.team import TeamMember, Role
class AddMemberForm(forms.Form):
email = forms.EmailField(
label="Email Address",
widget=forms.EmailInput(attrs={
'class': 'form-input',
'placeholder': 'user@example.com',
'autocomplete': 'email',
}),
help_text="User must be registered on the platform"
)
role = forms.ChoiceField(
label="Role",
choices=[(r.value, r.label) for r in Role if r != Role.OWNER],
initial=Role.MEMBER.value,
widget=forms.Select(attrs={'class': 'form-select'})
)
class EditMemberForm(forms.ModelForm):
class Meta:
model = TeamMember
fields = ['role']
widgets = {
'role': forms.Select(attrs={'class': 'form-select'})
}
**Request/Response Flow**:
1. **Initial Page Load**: User visits team page
.. code-block:: http
GET /teams/my-team/
Response: Full HTML page with team details and member list
2. **Click "Add Member"**: Opens modal with form
.. code-block:: http
GET /teams/members/add-form/
HX-Request: true
HX-Target: #modal-container
Response: Form HTML inserted into modal container
3. **Submit Form**: Add member and update multiple areas
.. code-block:: http
POST /teams/members/add/
HX-Request: true
HX-Target: #member-list
email=john@example.com&role=member
Response: