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

@@ -0,0 +1,305 @@
/**
* FSM Transitions JavaScript Module
*
* Provides enhanced UX for FSM state transitions including:
* - Toast notifications via HTMX event triggers
* - Loading indicators during transitions
* - Optimistic UI updates
* - Error handling and retry logic
* - Keyboard shortcuts for common transitions
*/
// =============================================================================
// Toast Integration for HTMX
// =============================================================================
/**
* Listen for showToast events triggered by HX-Trigger headers
* and display them using the Alpine.js toast store.
*/
document.body.addEventListener('showToast', function(evt) {
const detail = evt.detail;
if (detail && Alpine && Alpine.store('toast')) {
const toastStore = Alpine.store('toast');
const type = detail.type || 'info';
const message = detail.message || 'Operation completed';
switch (type) {
case 'success':
toastStore.success(message);
break;
case 'error':
toastStore.error(message);
break;
case 'warning':
toastStore.warning(message);
break;
case 'info':
default:
toastStore.info(message);
break;
}
}
});
// =============================================================================
// Transition Success/Error Handlers
// =============================================================================
/**
* Handle successful transition animations
*/
document.body.addEventListener('transitionSuccess', function(evt) {
const targetId = evt.detail?.targetId;
if (targetId) {
const element = document.getElementById(targetId);
if (element) {
// Add success flash animation
element.classList.add('animate-flash-success');
setTimeout(() => {
element.classList.remove('animate-flash-success');
}, 1000);
}
}
});
/**
* Handle transition errors with visual feedback
*/
document.body.addEventListener('transitionError', function(evt) {
const targetId = evt.detail?.targetId;
if (targetId) {
const element = document.getElementById(targetId);
if (element) {
// Add error shake animation
element.classList.add('animate-shake');
setTimeout(() => {
element.classList.remove('animate-shake');
}, 500);
}
}
});
// =============================================================================
// Loading State Management
// =============================================================================
/**
* Track loading states for transition buttons
*/
const loadingStates = new Map();
/**
* Show loading state on a button
*/
function showButtonLoading(button) {
if (!button) return;
const buttonId = button.id || button.dataset.transitionName;
if (!buttonId) return;
// Store original content
loadingStates.set(buttonId, {
innerHTML: button.innerHTML,
disabled: button.disabled,
});
// Show loading spinner
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Processing...';
button.classList.add('opacity-75', 'cursor-not-allowed');
}
/**
* Hide loading state and restore button
*/
function hideButtonLoading(button) {
if (!button) return;
const buttonId = button.id || button.dataset.transitionName;
const originalState = loadingStates.get(buttonId);
if (originalState) {
button.innerHTML = originalState.innerHTML;
button.disabled = originalState.disabled;
button.classList.remove('opacity-75', 'cursor-not-allowed');
loadingStates.delete(buttonId);
}
}
// Listen for HTMX before send to show loading
document.body.addEventListener('htmx:beforeSend', function(evt) {
const element = evt.detail.elt;
if (element && element.matches('[hx-post*="transition"]')) {
showButtonLoading(element);
}
});
// Listen for HTMX after settle to hide loading
document.body.addEventListener('htmx:afterSettle', function(evt) {
const element = evt.detail.elt;
if (element && element.matches('[hx-post*="transition"]')) {
hideButtonLoading(element);
}
});
// Also handle errors
document.body.addEventListener('htmx:responseError', function(evt) {
const element = evt.detail.elt;
if (element && element.matches('[hx-post*="transition"]')) {
hideButtonLoading(element);
}
});
// =============================================================================
// Keyboard Shortcuts
// =============================================================================
/**
* Keyboard shortcut configuration
* Ctrl/Cmd + key triggers the action
*/
const keyboardShortcuts = {
'a': 'approve', // Ctrl+A for approve
'r': 'reject', // Ctrl+R for reject
'e': 'escalate', // Ctrl+E for escalate
};
/**
* Handle keyboard shortcuts for transitions
* Only active when an item is selected/focused
*/
document.addEventListener('keydown', function(evt) {
// Only handle if Ctrl/Cmd is pressed
if (!(evt.ctrlKey || evt.metaKey)) return;
const key = evt.key.toLowerCase();
const action = keyboardShortcuts[key];
if (!action) return;
// Find the focused/selected item
const focusedItem = document.querySelector('.submission-item.selected, .queue-item.selected, [data-selected="true"]');
if (!focusedItem) return;
// Find the corresponding transition button
const transitionButton = focusedItem.querySelector(`[data-transition="${action}"], [hx-post*="${action}"]`);
if (transitionButton && !transitionButton.disabled) {
evt.preventDefault();
transitionButton.click();
}
});
// =============================================================================
// Batch Operations
// =============================================================================
/**
* Execute batch transitions on multiple selected items
*/
function executeBatchTransition(transitionName, itemIds) {
if (!transitionName || !itemIds || itemIds.length === 0) {
console.warn('Batch transition requires transitionName and itemIds');
return;
}
// Show confirmation for batch operations
const confirmed = confirm(`Are you sure you want to ${transitionName} ${itemIds.length} items?`);
if (!confirmed) return;
// Track progress
let completed = 0;
let failed = 0;
itemIds.forEach(itemId => {
const element = document.getElementById(itemId);
if (!element) return;
const transitionButton = element.querySelector(`[hx-post*="${transitionName}"]`);
if (transitionButton) {
// Use HTMX to trigger the transition
htmx.trigger(transitionButton, 'click');
completed++;
} else {
failed++;
}
});
// Show summary toast
if (Alpine && Alpine.store('toast')) {
if (failed === 0) {
Alpine.store('toast').success(`Batch ${transitionName} initiated for ${completed} items`);
} else {
Alpine.store('toast').warning(`Batch ${transitionName}: ${completed} succeeded, ${failed} failed`);
}
}
}
// Expose batch function globally
window.fsmBatchTransition = executeBatchTransition;
// =============================================================================
// Status Change Event Dispatchers for Parks and Rides
// =============================================================================
/**
* Listen for successful FSM transitions and dispatch custom events
* to refresh status sections on detail pages.
*/
document.body.addEventListener('htmx:afterOnLoad', function(evt) {
const triggerHeader = evt.detail.xhr.getResponseHeader('HX-Trigger');
if (triggerHeader) {
try {
const triggers = JSON.parse(triggerHeader);
// Check if this was a transition request
const requestPath = evt.detail.pathInfo?.requestPath || '';
// Dispatch status change events for parks and rides
if (requestPath.includes('/parks/') && requestPath.includes('/transition/')) {
document.body.dispatchEvent(new CustomEvent('park-status-changed'));
}
if (requestPath.includes('/rides/') && requestPath.includes('/transition/')) {
document.body.dispatchEvent(new CustomEvent('ride-status-changed'));
}
} catch (e) {
// Ignore parsing errors
}
}
});
// =============================================================================
// Initialization
// =============================================================================
document.addEventListener('DOMContentLoaded', function() {
console.log('FSM Transitions module initialized');
// Add CSS for animations if not present
if (!document.getElementById('fsm-animations-style')) {
const style = document.createElement('style');
style.id = 'fsm-animations-style';
style.textContent = `
@keyframes flash-success {
0%, 100% { background-color: transparent; }
50% { background-color: rgba(34, 197, 94, 0.2); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-5px); }
40%, 80% { transform: translateX(5px); }
}
.animate-flash-success {
animation: flash-success 1s ease-in-out;
}
.animate-shake {
animation: shake 0.5s ease-in-out;
}
`;
document.head.appendChild(style);
}
});

View File

@@ -1,377 +1,27 @@
/**
* Moderation Transition History JavaScript
* Handles AJAX loading and display of FSM transition history
* DEPRECATED: This file is no longer used.
*
* History functionality has been migrated to HTMX-based implementation.
*
* See the following files for the new implementation:
* - backend/templates/moderation/history.html
* - backend/templates/moderation/partials/history_table.html
* - backend/templates/moderation/partials/history_filters.html
* - backend/templates/moderation/partials/history_pagination.html
* - backend/templates/moderation/partials/history_detail_modal.html
*
* The HTMX implementation provides:
* - Server-side rendered history table
* - HTMX-based filtering and pagination
* - Alpine.js modal for detail views
* - Integration with the moderation dashboard
*
* This file will be removed in a future version.
*/
let currentPage = 1;
let nextPageUrl = null;
let previousPageUrl = null;
console.warn(
'[DEPRECATED] history.js is deprecated and will be removed in a future version. ' +
'History functionality has been migrated to HTMX. See moderation/history.html for the new implementation.'
);
/**
* Format timestamp to human-readable format
*/
function formatTimestamp(timestamp) {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
/**
* Get CSRF token from cookie
*/
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
/**
* Fetch and display transition history
*/
function loadHistory(url = null, filters = {}) {
const tbody = document.getElementById('history-tbody');
tbody.innerHTML = '<tr class="loading-row"><td colspan="7" class="text-center"><div class="spinner"></div> Loading history...</td></tr>';
// Build URL
let fetchUrl = url || '/api/moderation/reports/all_history/';
// Add filters to URL if no custom URL provided
if (!url && Object.keys(filters).length > 0) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters)) {
if (value) {
params.append(key, value);
}
}
fetchUrl += '?' + params.toString();
}
fetch(fetchUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
credentials: 'same-origin'
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
renderHistoryTable(data.results || data);
updatePagination(data);
})
.catch(error => {
console.error('Error loading history:', error);
tbody.innerHTML = '<tr><td colspan="7" class="text-center" style="color: red;">Error loading history. Please try again.</td></tr>';
});
}
/**
* Render history table rows
*/
function renderHistoryTable(logs) {
const tbody = document.getElementById('history-tbody');
if (!logs || logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center">No transition history found.</td></tr>';
return;
}
tbody.innerHTML = logs.map(log => `
<tr>
<td>${formatTimestamp(log.timestamp)}</td>
<td><span class="badge badge-model">${log.model}</span></td>
<td><a href="/moderation/${log.model}/${log.object_id}" class="object-link">${log.object_id}</a></td>
<td><span class="badge badge-transition">${log.transition || '-'}</span></td>
<td><span class="badge badge-state badge-state-${log.state}">${log.state}</span></td>
<td>${log.user || '<em>System</em>'}</td>
<td><button onclick="viewDetails(${log.id})" class="btn btn-sm btn-view">View</button></td>
</tr>
`).join('');
}
/**
* Update pagination controls
*/
function updatePagination(data) {
nextPageUrl = data.next || null;
previousPageUrl = data.previous || null;
const prevBtn = document.getElementById('prev-page');
const nextBtn = document.getElementById('next-page');
const pageInfo = document.getElementById('page-info');
prevBtn.disabled = !previousPageUrl;
nextBtn.disabled = !nextPageUrl;
// Calculate page number from count
if (data.count) {
const resultsPerPage = data.results ? data.results.length : 0;
const totalPages = Math.ceil(data.count / (resultsPerPage || 1));
pageInfo.textContent = `Page ${currentPage} of ${totalPages}`;
} else {
pageInfo.textContent = `Page ${currentPage}`;
}
}
/**
* View details modal
*/
function viewDetails(logId) {
const modal = document.getElementById('details-modal');
const modalBody = document.getElementById('modal-body');
modalBody.innerHTML = '<div class="spinner"></div> Loading details...';
modal.style.display = 'flex';
// Fetch detailed information filtered by id
fetch(`/api/moderation/reports/all_history/?id=${logId}`, {
headers: {
'X-CSRFToken': getCookie('csrftoken')
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(data => {
// Handle both paginated and non-paginated responses
let log = null;
if (data.results && data.results.length > 0) {
log = data.results[0];
} else if (Array.isArray(data) && data.length > 0) {
log = data[0];
} else if (data.id) {
// Single object response
log = data;
}
if (log) {
modalBody.innerHTML = `
<div class="detail-grid">
<div class="detail-item">
<strong>ID:</strong> ${log.id}
</div>
<div class="detail-item">
<strong>Timestamp:</strong> ${formatTimestamp(log.timestamp)}
</div>
<div class="detail-item">
<strong>Model:</strong> ${log.model}
</div>
<div class="detail-item">
<strong>Object ID:</strong> ${log.object_id}
</div>
<div class="detail-item">
<strong>Transition:</strong> ${log.transition || '-'}
</div>
<div class="detail-item">
<strong>From State:</strong> ${log.from_state || '-'}
</div>
<div class="detail-item">
<strong>To State:</strong> ${log.to_state || log.state || '-'}
</div>
<div class="detail-item">
<strong>User:</strong> ${log.user || 'System'}
</div>
${log.reason ? `<div class="detail-item full-width"><strong>Reason:</strong><br>${log.reason}</div>` : ''}
${log.description ? `<div class="detail-item full-width"><strong>Description:</strong><br>${log.description}</div>` : ''}
</div>
`;
} else {
modalBody.innerHTML = '<p>No log entry found with this ID.</p>';
}
})
.catch(error => {
console.error('Error loading details:', error);
modalBody.innerHTML = '<p style="color: red;">Error loading details.</p>';
});
}
/**
* Close modal
*/
function closeModal() {
const modal = document.getElementById('details-modal');
modal.style.display = 'none';
}
/**
* Get current filters
*/
function getCurrentFilters() {
return {
model_type: document.getElementById('model-filter').value,
state: document.getElementById('state-filter').value,
start_date: document.getElementById('start-date').value,
end_date: document.getElementById('end-date').value,
user_id: document.getElementById('user-filter').value,
};
}
/**
* Event listeners
*/
document.addEventListener('DOMContentLoaded', function() {
// Apply filters button
document.getElementById('apply-filters').addEventListener('click', () => {
currentPage = 1;
const filters = getCurrentFilters();
loadHistory(null, filters);
});
// Clear filters button
document.getElementById('clear-filters').addEventListener('click', () => {
document.getElementById('model-filter').value = '';
document.getElementById('state-filter').value = '';
document.getElementById('start-date').value = '';
document.getElementById('end-date').value = '';
document.getElementById('user-filter').value = '';
currentPage = 1;
loadHistory();
});
// Pagination buttons
document.getElementById('prev-page').addEventListener('click', () => {
if (previousPageUrl) {
currentPage--;
loadHistory(previousPageUrl);
}
});
document.getElementById('next-page').addEventListener('click', () => {
if (nextPageUrl) {
currentPage++;
loadHistory(nextPageUrl);
}
});
// Close modal on background click
document.getElementById('details-modal').addEventListener('click', (e) => {
if (e.target.id === 'details-modal') {
closeModal();
}
});
// Initial load
loadHistory();
});
// Additional CSS for badges (inline styles)
const style = document.createElement('style');
style.textContent = `
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-model {
background-color: #e7f3ff;
color: #0066cc;
}
.badge-transition {
background-color: #fff3cd;
color: #856404;
}
.badge-state {
background-color: #d4edda;
color: #155724;
}
.badge-state-PENDING {
background-color: #fff3cd;
color: #856404;
}
.badge-state-APPROVED {
background-color: #d4edda;
color: #155724;
}
.badge-state-REJECTED {
background-color: #f8d7da;
color: #721c24;
}
.badge-state-IN_PROGRESS {
background-color: #d1ecf1;
color: #0c5460;
}
.badge-state-COMPLETED {
background-color: #d4edda;
color: #155724;
}
.badge-state-ESCALATED {
background-color: #f8d7da;
color: #721c24;
}
.object-link {
color: #007bff;
text-decoration: none;
}
.object-link:hover {
text-decoration: underline;
}
.btn-view {
background-color: #007bff;
color: white;
border: none;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
}
.btn-view:hover {
background-color: #0056b3;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 15px;
}
.detail-item {
padding: 10px;
background-color: #f8f9fa;
border-radius: 4px;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.detail-item strong {
display: block;
margin-bottom: 5px;
color: #666;
font-size: 0.875rem;
}
`;
document.head.appendChild(style);
// No functionality provided - all history features now use HTMX