mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 05:51:19 -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:
@@ -2,60 +2,99 @@
|
||||
|
||||
<h1 class="mb-6 text-2xl font-bold text-gray-900 dark:text-gray-200">Moderation Dashboard</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-6" x-data="{ activeTab: 'submissions' }">
|
||||
<!-- Tab Navigation -->
|
||||
<div class="flex items-center justify-between p-4 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="{% url 'moderation:submission_list' %}?status=PENDING"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'PENDING' or not request.GET.status %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=PENDING"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-clock"></i>
|
||||
<span>Pending</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'APPROVED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-check"></i>
|
||||
<span>Approved</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'REJECTED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-times"></i>
|
||||
<span>Rejected</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'ESCALATED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-exclamation-triangle"></i>
|
||||
<span>Escalated</span>
|
||||
</a>
|
||||
<!-- Submissions Tab -->
|
||||
<button type="button"
|
||||
@click="activeTab = 'submissions'"
|
||||
:class="activeTab === 'submissions' ? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200">
|
||||
<i class="mr-2.5 text-lg fas fa-file-alt"></i>
|
||||
<span>Submissions</span>
|
||||
</button>
|
||||
|
||||
<!-- History Tab -->
|
||||
<button type="button"
|
||||
@click="activeTab = 'history'"
|
||||
:class="activeTab === 'history' ? 'bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300'"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200">
|
||||
<i class="mr-2.5 text-lg fas fa-history"></i>
|
||||
<span>History</span>
|
||||
</button>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="w-px h-8 bg-gray-200 dark:bg-gray-700" x-show="activeTab === 'submissions'"></div>
|
||||
|
||||
<!-- Status Filters (only visible in Submissions tab) -->
|
||||
<template x-if="activeTab === 'submissions'">
|
||||
<div class="flex items-center space-x-2">
|
||||
<a href="{% url 'moderation:submission_list' %}?status=PENDING"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'PENDING' or not request.GET.status %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=PENDING"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-clock"></i>
|
||||
<span>Pending</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'APPROVED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=APPROVED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-check"></i>
|
||||
<span>Approved</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'REJECTED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=REJECTED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-times"></i>
|
||||
<span>Rejected</span>
|
||||
</a>
|
||||
|
||||
<a href="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 {% if request.GET.status == 'ESCALATED' %}bg-blue-100 text-blue-900 dark:bg-blue-900/40 dark:text-blue-400{% else %}text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-300{% endif %}"
|
||||
hx-get="{% url 'moderation:submission_list' %}?status=ESCALATED"
|
||||
hx-target="#dashboard-content"
|
||||
hx-push-url="true"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-exclamation-triangle"></i>
|
||||
<span>Escalated</span>
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 text-gray-600 dark:text-gray-400 hover:text-blue-900 hover:bg-blue-100 dark:hover:text-blue-400 dark:hover:bg-blue-900/40"
|
||||
hx-get="{{ request.get_full_path }}"
|
||||
hx-target="#dashboard-content"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-sync-alt"></i>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- View Full History Link (visible in History tab) -->
|
||||
<a href="{% url 'moderation:history' %}"
|
||||
x-show="activeTab === 'history'"
|
||||
x-cloak
|
||||
class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 text-blue-600 dark:text-blue-400 hover:text-blue-900 hover:bg-blue-100 dark:hover:text-blue-300 dark:hover:bg-blue-900/40">
|
||||
<i class="mr-2.5 text-lg fas fa-external-link-alt"></i>
|
||||
<span>Full History</span>
|
||||
</a>
|
||||
|
||||
<button class="flex items-center px-4 py-2.5 rounded-lg font-medium transition-all duration-200 text-gray-600 dark:text-gray-400 hover:text-blue-900 hover:bg-blue-100 dark:hover:text-blue-400 dark:hover:bg-blue-900/40"
|
||||
hx-get="{{ request.get_full_path }}"
|
||||
hx-target="#dashboard-content"
|
||||
hx-indicator="#loading-indicator">
|
||||
<i class="mr-2.5 text-lg fas fa-sync-alt"></i>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<!-- Submissions Tab Panel -->
|
||||
<div x-show="activeTab === 'submissions'" class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<form class="mb-6"
|
||||
x-data="{ showFilters: false }"
|
||||
hx-get="{% url 'moderation:submission_list' %}"
|
||||
@@ -63,7 +102,7 @@
|
||||
hx-trigger="change from:select"
|
||||
hx-push-url="true"
|
||||
aria-label="Submission filters">
|
||||
|
||||
|
||||
<!-- Mobile Filter Toggle -->
|
||||
<button type="button"
|
||||
class="flex items-center w-full gap-2 p-3 mb-4 font-medium text-left text-gray-700 transition-colors duration-200 bg-gray-100 rounded-lg md:hidden dark:text-gray-300 dark:bg-gray-900"
|
||||
@@ -83,7 +122,7 @@
|
||||
:class="{'hidden md:grid': !showFilters, 'grid': showFilters}"
|
||||
role="group"
|
||||
aria-label="Filter controls">
|
||||
|
||||
|
||||
<div class="relative">
|
||||
<label id="submission-type-label"
|
||||
class="block mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
@@ -150,28 +189,60 @@
|
||||
{% include "moderation/partials/submission_list.html" with submissions=submissions user=user %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Tab Panel -->
|
||||
<div x-show="activeTab === 'history'" x-cloak>
|
||||
<div id="dashboard-history-container"
|
||||
hx-get="{% url 'moderation:moderation-reports-all-history' %}"
|
||||
hx-trigger="intersect once"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#dashboard-history-loading">
|
||||
<!-- Initial Loading State -->
|
||||
<div class="p-6 bg-white border rounded-lg dark:bg-gray-800 border-gray-200/50 dark:border-gray-700/50">
|
||||
<div id="dashboard-history-loading" class="flex flex-col items-center justify-center py-8">
|
||||
<i class="mb-2 text-2xl text-blue-500 fas fa-spinner fa-spin"></i>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">Loading history...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Detail Modal -->
|
||||
{% include 'moderation/partials/history_detail_modal.html' %}
|
||||
</div>
|
||||
|
||||
<div id="toast-container"
|
||||
x-data="{
|
||||
show: false,
|
||||
<div id="toast-container"
|
||||
data-toast
|
||||
x-data="{
|
||||
show: false,
|
||||
message: '',
|
||||
type: 'success',
|
||||
icon: 'check',
|
||||
color: 'green',
|
||||
showToast(msg, icn = 'check', clr = 'green') {
|
||||
this.message = msg;
|
||||
this.icon = icn;
|
||||
this.color = clr;
|
||||
showToast(data) {
|
||||
if (typeof data === 'string') {
|
||||
this.message = data;
|
||||
this.type = 'success';
|
||||
} else {
|
||||
this.message = data.message || 'Action completed';
|
||||
this.type = data.type || 'success';
|
||||
}
|
||||
// Set icon based on type
|
||||
this.icon = this.type === 'success' ? 'check' :
|
||||
this.type === 'error' ? 'times' :
|
||||
this.type === 'warning' ? 'exclamation-triangle' :
|
||||
this.type === 'info' ? 'info-circle' : 'check';
|
||||
this.show = true;
|
||||
setTimeout(() => this.show = false, 3000);
|
||||
}
|
||||
}"
|
||||
@submission-approved.window="showToast('Submission approved successfully', 'check', 'green')"
|
||||
@submission-rejected.window="showToast('Submission rejected', 'times', 'red')"
|
||||
@submission-escalated.window="showToast('Submission escalated', 'exclamation-triangle', 'yellow')"
|
||||
@submission-updated.window="showToast('Changes saved successfully', 'check', 'blue')"
|
||||
@show-toast.window="showToast($event.detail)"
|
||||
@submission-approved.window="showToast({message: 'Submission approved successfully', type: 'success'})"
|
||||
@submission-rejected.window="showToast({message: 'Submission rejected', type: 'error'})"
|
||||
@submission-escalated.window="showToast({message: 'Submission escalated', type: 'warning'})"
|
||||
@submission-updated.window="showToast({message: 'Changes saved successfully', type: 'info'})"
|
||||
class="fixed z-50 bottom-4 right-4">
|
||||
|
||||
|
||||
<div x-show="show"
|
||||
x-transition:enter="ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-y-full"
|
||||
@@ -182,15 +253,15 @@
|
||||
class="flex items-center w-full max-w-xs p-4 text-gray-400 bg-gray-800 rounded-lg shadow">
|
||||
<div class="inline-flex items-center justify-center shrink-0 w-8 h-8 rounded-lg"
|
||||
:class="{
|
||||
'text-green-400 bg-green-900/40': color === 'green',
|
||||
'text-red-400 bg-red-900/40': color === 'red',
|
||||
'text-yellow-400 bg-yellow-900/40': color === 'yellow',
|
||||
'text-blue-400 bg-blue-900/40': color === 'blue'
|
||||
'text-green-400 bg-green-900/40': type === 'success',
|
||||
'text-red-400 bg-red-900/40': type === 'error',
|
||||
'text-yellow-400 bg-yellow-900/40': type === 'warning',
|
||||
'text-blue-400 bg-blue-900/40': type === 'info'
|
||||
}">
|
||||
<i class="fas" :class="'fa-' + icon"></i>
|
||||
</div>
|
||||
<div class="ml-3 text-sm font-normal" x-text="message"></div>
|
||||
<button type="button"
|
||||
<button type="button"
|
||||
class="ml-auto -mx-1.5 -my-1.5 text-gray-400 hover:text-gray-300 rounded-lg p-1.5 inline-flex h-8 w-8"
|
||||
@click="show = false">
|
||||
<i class="fas fa-times"></i>
|
||||
@@ -199,23 +270,59 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Handle HX-Trigger headers from FSMTransitionView for toast notifications
|
||||
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
|
||||
const triggerHeader = evt.detail.xhr.getResponseHeader('HX-Trigger');
|
||||
if (triggerHeader) {
|
||||
try {
|
||||
const triggers = JSON.parse(triggerHeader);
|
||||
if (triggers.showToast) {
|
||||
window.dispatchEvent(new CustomEvent('show-toast', { detail: triggers.showToast }));
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle non-JSON trigger headers (simple event names)
|
||||
console.debug('Non-JSON HX-Trigger header:', triggerHeader);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle HX-Trigger headers on error responses for toast notifications
|
||||
document.body.addEventListener('htmx:responseError', function(evt) {
|
||||
const triggerHeader = evt.detail.xhr.getResponseHeader('HX-Trigger');
|
||||
if (triggerHeader) {
|
||||
try {
|
||||
const triggers = JSON.parse(triggerHeader);
|
||||
if (triggers.showToast) {
|
||||
window.dispatchEvent(new CustomEvent('show-toast', { detail: triggers.showToast }));
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle non-JSON trigger headers (simple event names)
|
||||
console.debug('Non-JSON HX-Trigger header:', triggerHeader);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback: Handle legacy URL-based toast dispatching for backward compatibility
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.successful) {
|
||||
const path = evt.detail.requestConfig.path;
|
||||
let event;
|
||||
|
||||
if (path.includes('approve')) {
|
||||
event = new CustomEvent('submission-approved');
|
||||
} else if (path.includes('reject')) {
|
||||
event = new CustomEvent('submission-rejected');
|
||||
} else if (path.includes('escalate')) {
|
||||
event = new CustomEvent('submission-escalated');
|
||||
} else if (path.includes('edit')) {
|
||||
event = new CustomEvent('submission-updated');
|
||||
}
|
||||
|
||||
if (event) {
|
||||
window.dispatchEvent(event);
|
||||
|
||||
// Only dispatch if it's an old-style URL (not FSM transition URL)
|
||||
if (!path.includes('/transition/')) {
|
||||
if (path.includes('approve')) {
|
||||
event = new CustomEvent('submission-approved');
|
||||
} else if (path.includes('reject')) {
|
||||
event = new CustomEvent('submission-rejected');
|
||||
} else if (path.includes('escalate')) {
|
||||
event = new CustomEvent('submission-escalated');
|
||||
} else if (path.includes('edit')) {
|
||||
event = new CustomEvent('submission-updated');
|
||||
}
|
||||
|
||||
if (event) {
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user