mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-06 01:05:15 -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:
305
backend/static/js/fsm-transitions.js
Normal file
305
backend/static/js/fsm-transitions.js
Normal 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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user