mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 22:11:08 -05:00
Add test utilities and state machine diagrams for FSM models
- Introduced reusable test utilities in `backend/tests/utils` for FSM transitions, HTMX interactions, and common scenarios. - Added factory functions for creating test submissions, parks, rides, and photo submissions. - Implemented assertion helpers for verifying state changes, toast notifications, and transition logs. - Created comprehensive state machine diagrams for all FSM-enabled models in `docs/STATE_DIAGRAMS.md`, detailing states, transitions, and guard conditions.
This commit is contained in:
@@ -1,3 +1,33 @@
|
||||
<div class="htmx-loading-indicator" aria-hidden="true">
|
||||
<div class="spinner">Loading…</div>
|
||||
{% comment %}
|
||||
Loading Indicator Component
|
||||
|
||||
Displays a loading spinner for HTMX requests.
|
||||
|
||||
Optional context:
|
||||
- size: 'sm', 'md', or 'lg' (defaults to 'md')
|
||||
- inline: Whether to render inline (defaults to false)
|
||||
- message: Loading message text (defaults to 'Loading...')
|
||||
- id: Optional ID for the indicator element
|
||||
{% endcomment %}
|
||||
|
||||
{% if inline %}
|
||||
<!-- Inline Loading Indicator -->
|
||||
<span class="htmx-indicator inline-flex items-center gap-2 {% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% endif %}"
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
aria-hidden="true">
|
||||
<i class="fas fa-spinner fa-spin text-blue-500"></i>
|
||||
{% if message %}<span class="text-gray-500 dark:text-gray-400">{{ message }}</span>{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<!-- Block Loading Indicator -->
|
||||
<div class="htmx-indicator flex items-center justify-center p-4 {% if size == 'sm' %}p-2{% elif size == 'lg' %}p-6{% endif %}"
|
||||
{% if id %}id="{{ id }}"{% endif %}
|
||||
aria-hidden="true">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="{% if size == 'sm' %}w-5 h-5{% elif size == 'lg' %}w-10 h-10{% else %}w-8 h-8{% endif %} border-4 border-blue-500 rounded-full animate-spin border-t-transparent"></div>
|
||||
<span class="{% if size == 'sm' %}text-sm{% elif size == 'lg' %}text-lg{% else %}text-base{% endif %} text-gray-600 dark:text-gray-300">
|
||||
{{ message|default:"Loading..." }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
58
backend/templates/htmx/state_actions.html
Normal file
58
backend/templates/htmx/state_actions.html
Normal file
@@ -0,0 +1,58 @@
|
||||
{% comment %}
|
||||
FSM State Actions Partial Template
|
||||
|
||||
Renders available transition buttons for an FSM-enabled object.
|
||||
Uses HTMX for seamless state transitions with toast notifications.
|
||||
|
||||
Required context:
|
||||
- object: The FSM-enabled model instance
|
||||
- user: The current user (usually request.user)
|
||||
|
||||
Optional context:
|
||||
- target_id: The ID of the element to swap after transition (defaults to object-{{ object.id }})
|
||||
- button_size: 'sm', 'md', or 'lg' (defaults to 'md')
|
||||
- show_labels: Whether to show button labels (defaults to true)
|
||||
- inline: Whether to render buttons inline (defaults to false)
|
||||
{% endcomment %}
|
||||
{% load fsm_tags %}
|
||||
|
||||
{% get_available_transitions object user as transitions %}
|
||||
|
||||
{% if transitions %}
|
||||
<div class="fsm-actions flex {% if inline %}flex-row gap-2{% else %}flex-wrap gap-2{% endif %}"
|
||||
id="actions-{{ object.id }}">
|
||||
{% for transition in transitions %}
|
||||
<button
|
||||
type="button"
|
||||
hx-post="{% url 'core:fsm_transition' app_label=object|app_label model_name=object|model_name pk=object.pk transition_name=transition.name %}"
|
||||
hx-target="#{{ target_id|default:object|default_target_id }}"
|
||||
hx-swap="outerHTML"
|
||||
{% if transition.requires_confirm %}
|
||||
hx-confirm="{{ transition.confirm_message|default:'Are you sure?' }}"
|
||||
{% endif %}
|
||||
hx-indicator="#loading-{{ object.id }}"
|
||||
class="inline-flex items-center justify-center gap-1.5 px-{% if button_size == 'sm' %}2.5 py-1.5 text-xs{% elif button_size == 'lg' %}5 py-3 text-base{% else %}4 py-2.5 text-sm{% endif %} font-medium rounded-lg transition-all duration-200 shadow-xs hover:shadow-md
|
||||
{% if transition.style == 'green' %}
|
||||
bg-green-600 text-white hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600
|
||||
{% elif transition.style == 'red' %}
|
||||
bg-red-600 text-white hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600
|
||||
{% elif transition.style == 'yellow' %}
|
||||
bg-yellow-600 text-white hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600
|
||||
{% elif transition.style == 'blue' %}
|
||||
bg-blue-600 text-white hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-600
|
||||
{% else %}
|
||||
bg-gray-600 text-white hover:bg-gray-500 dark:bg-gray-700 dark:hover:bg-gray-600
|
||||
{% endif %}">
|
||||
<i class="fas fa-{{ transition.icon|default:'arrow-right' }}"></i>
|
||||
{% if show_labels|default:True %}
|
||||
<span>{{ transition.label }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<span id="loading-{{ object.id }}" class="htmx-indicator inline-flex items-center">
|
||||
<i class="fas fa-spinner fa-spin text-blue-500"></i>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
148
backend/templates/htmx/status_with_actions.html
Normal file
148
backend/templates/htmx/status_with_actions.html
Normal file
@@ -0,0 +1,148 @@
|
||||
{% comment %}
|
||||
FSM Status Badge with Actions Partial Template
|
||||
|
||||
Displays current status badge alongside available transition buttons.
|
||||
Combines status display with action capabilities for a cohesive UX.
|
||||
|
||||
Required context:
|
||||
- object: The FSM-enabled model instance
|
||||
- user: The current user (usually request.user)
|
||||
|
||||
Optional context:
|
||||
- target_id: The ID of the element to swap after transition
|
||||
- show_badge: Whether to show the status badge (defaults to true)
|
||||
- badge_only: Only show the badge, no actions (defaults to false)
|
||||
- dropdown_actions: Show actions in a dropdown menu (defaults to false)
|
||||
- compact: Use compact layout (defaults to false)
|
||||
{% endcomment %}
|
||||
{% load fsm_tags %}
|
||||
|
||||
{% get_available_transitions object user as transitions %}
|
||||
|
||||
<div class="status-with-actions flex items-center gap-3 {% if compact %}gap-2{% endif %}">
|
||||
|
||||
{% if show_badge|default:True %}
|
||||
<!-- Status Badge -->
|
||||
<span class="status-badge inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full" data-status-badge
|
||||
{% with status=object|get_state_value %}
|
||||
{% if status == 'PENDING' %}
|
||||
bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
|
||||
{% elif status == 'APPROVED' %}
|
||||
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
|
||||
{% elif status == 'REJECTED' %}
|
||||
bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
|
||||
{% elif status == 'ESCALATED' %}
|
||||
bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300
|
||||
{% elif status == 'IN_PROGRESS' or status == 'PROCESSING' %}
|
||||
bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
|
||||
{% elif status == 'COMPLETED' or status == 'RESOLVED' %}
|
||||
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
|
||||
{% elif status == 'CANCELLED' or status == 'DISMISSED' %}
|
||||
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
|
||||
{% else %}
|
||||
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
|
||||
{% endif %}
|
||||
{% endwith %}">
|
||||
{% with choice=object|get_state_choice %}
|
||||
{% if choice and choice.metadata.icon %}
|
||||
<i class="fas fa-{{ choice.metadata.icon }}"></i>
|
||||
{% else %}
|
||||
{% with status=object|get_state_value %}
|
||||
<i class="fas fa-{% if status == 'PENDING' %}clock{% elif status == 'APPROVED' %}check{% elif status == 'REJECTED' %}times{% elif status == 'ESCALATED' %}exclamation{% elif status == 'IN_PROGRESS' or status == 'PROCESSING' %}spinner{% elif status == 'COMPLETED' or status == 'RESOLVED' %}check-circle{% else %}circle{% endif %}"></i>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{{ object|get_state_display }}
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if not badge_only %}
|
||||
{% if transitions %}
|
||||
{% if dropdown_actions %}
|
||||
<!-- Dropdown Actions -->
|
||||
<div class="relative" x-data="{ open: false }">
|
||||
<button
|
||||
@click="open = !open"
|
||||
@click.outside="open = false"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
|
||||
<span>Actions</span>
|
||||
<i class="fas fa-chevron-down text-xs transition-transform" :class="{ 'rotate-180': open }"></i>
|
||||
</button>
|
||||
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="transform opacity-0 scale-95"
|
||||
x-transition:enter-end="transform opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-75"
|
||||
x-transition:leave-start="transform opacity-100 scale-100"
|
||||
x-transition:leave-end="transform opacity-0 scale-95"
|
||||
class="absolute right-0 mt-2 w-48 bg-white border border-gray-200 rounded-lg shadow-lg dark:bg-gray-800 dark:border-gray-700 z-50"
|
||||
x-cloak>
|
||||
<div class="py-1">
|
||||
{% for transition in transitions %}
|
||||
<button
|
||||
type="button"
|
||||
hx-post="{% url 'core:fsm_transition' app_label=object|app_label model_name=object|model_name pk=object.pk transition_name=transition.name %}"
|
||||
hx-target="#{{ target_id|default:object|default_target_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-include="closest .review-notes textarea[name='notes']"
|
||||
{% if transition.requires_confirm %}
|
||||
hx-confirm="{{ transition.confirm_message|default:'Are you sure?' }}"
|
||||
{% endif %}
|
||||
@click="open = false"
|
||||
class="w-full flex items-center gap-2 px-4 py-2 text-sm text-left
|
||||
{% if transition.style == 'green' %}text-green-700 hover:bg-green-50 dark:text-green-400 dark:hover:bg-green-900/30
|
||||
{% elif transition.style == 'red' %}text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/30
|
||||
{% elif transition.style == 'yellow' %}text-yellow-700 hover:bg-yellow-50 dark:text-yellow-400 dark:hover:bg-yellow-900/30
|
||||
{% elif transition.style == 'blue' %}text-blue-700 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-900/30
|
||||
{% else %}text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-700
|
||||
{% endif %}">
|
||||
<i class="fas fa-{{ transition.icon|default:'arrow-right' }} w-4"></i>
|
||||
{{ transition.label }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Inline Actions -->
|
||||
<div class="inline-flex items-center gap-2">
|
||||
{% for transition in transitions %}
|
||||
<button
|
||||
type="button"
|
||||
hx-post="{% url 'core:fsm_transition' app_label=object|app_label model_name=object|model_name pk=object.pk transition_name=transition.name %}"
|
||||
hx-target="#{{ target_id|default:object|default_target_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-include="closest .review-notes textarea[name='notes']"
|
||||
{% if transition.requires_confirm %}
|
||||
hx-confirm="{{ transition.confirm_message|default:'Are you sure?' }}"
|
||||
{% endif %}
|
||||
hx-indicator="#loading-{{ object.id }}"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg transition-all duration-200
|
||||
{% if transition.style == 'green' %}
|
||||
bg-green-600 text-white hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600
|
||||
{% elif transition.style == 'red' %}
|
||||
bg-red-600 text-white hover:bg-red-500 dark:bg-red-700 dark:hover:bg-red-600
|
||||
{% elif transition.style == 'yellow' %}
|
||||
bg-yellow-600 text-white hover:bg-yellow-500 dark:bg-yellow-700 dark:hover:bg-yellow-600
|
||||
{% elif transition.style == 'blue' %}
|
||||
bg-blue-600 text-white hover:bg-blue-500 dark:bg-blue-700 dark:hover:bg-blue-600
|
||||
{% else %}
|
||||
bg-gray-600 text-white hover:bg-gray-500 dark:bg-gray-700 dark:hover:bg-gray-600
|
||||
{% endif %}">
|
||||
<i class="fas fa-{{ transition.icon|default:'arrow-right' }}"></i>
|
||||
<span>{{ transition.label }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<span id="loading-{{ object.id }}" class="htmx-indicator">
|
||||
<i class="fas fa-spinner fa-spin text-blue-500"></i>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
80
backend/templates/htmx/updated_row.html
Normal file
80
backend/templates/htmx/updated_row.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{% comment %}
|
||||
FSM Updated Row Partial Template
|
||||
|
||||
Generic template for rendering an updated table row after an FSM transition.
|
||||
Used as the default response template for FSMTransitionView.
|
||||
|
||||
Required context:
|
||||
- object: The FSM-enabled model instance
|
||||
- user: The current user (usually request.user)
|
||||
|
||||
Optional context:
|
||||
- transition_success: Whether a transition just succeeded
|
||||
- success_message: Success message to display (handled by toast)
|
||||
- show_actions: Whether to show action buttons (defaults to true)
|
||||
- row_class: Additional CSS classes for the row
|
||||
{% endcomment %}
|
||||
{% load fsm_tags %}
|
||||
|
||||
<tr id="{{ object|default_target_id }}"
|
||||
class="{% if transition_success %}animate-flash-success{% endif %} {{ row_class|default:'' }} border-b border-gray-200/50 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
|
||||
<!-- Status Cell -->
|
||||
<td class="px-4 py-3">
|
||||
<span class="status-badge inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full
|
||||
{% with status=object|get_state_value %}
|
||||
{% if status == 'PENDING' %}
|
||||
bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
|
||||
{% elif status == 'APPROVED' %}
|
||||
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
|
||||
{% elif status == 'REJECTED' %}
|
||||
bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300
|
||||
{% elif status == 'ESCALATED' %}
|
||||
bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300
|
||||
{% elif status == 'IN_PROGRESS' or status == 'PROCESSING' %}
|
||||
bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
|
||||
{% elif status == 'COMPLETED' or status == 'RESOLVED' %}
|
||||
bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
|
||||
{% else %}
|
||||
bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300
|
||||
{% endif %}
|
||||
{% endwith %}">
|
||||
{% with status=object|get_state_value %}
|
||||
<i class="fas fa-{% if status == 'PENDING' %}clock{% elif status == 'APPROVED' %}check{% elif status == 'REJECTED' %}times{% elif status == 'ESCALATED' %}exclamation{% elif status == 'IN_PROGRESS' or status == 'PROCESSING' %}spinner{% elif status == 'COMPLETED' or status == 'RESOLVED' %}check-circle{% else %}circle{% endif %}"></i>
|
||||
{% endwith %}
|
||||
{{ object|get_state_display }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- Object Info Cell -->
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ object }}
|
||||
</div>
|
||||
{% if object.created_at %}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ object.created_at|date:"M d, Y H:i" }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Actions Cell -->
|
||||
{% if show_actions|default:True %}
|
||||
<td class="px-4 py-3 text-right">
|
||||
{% include 'htmx/state_actions.html' with object=object user=user button_size='sm' inline=True %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
|
||||
{% comment %}
|
||||
CSS for flash animation - add to your CSS file or include inline:
|
||||
|
||||
@keyframes flash-success {
|
||||
0%, 100% { background-color: transparent; }
|
||||
50% { background-color: rgba(34, 197, 94, 0.2); }
|
||||
}
|
||||
|
||||
.animate-flash-success {
|
||||
animation: flash-success 1s ease-in-out;
|
||||
}
|
||||
{% endcomment %}
|
||||
Reference in New Issue
Block a user