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:
pacnpal
2025-12-22 08:55:39 -05:00
parent b508434574
commit 45d97b6e68
71 changed files with 8608 additions and 633 deletions

View File

@@ -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 %}

View 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 %}

View 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>

View 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 %}