Files
thrillwiki_django_no_react/backend/static/js/fsm-transitions.js
pacnpal 45d97b6e68 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.
2025-12-22 08:55:39 -05:00

306 lines
9.7 KiB
JavaScript

/**
* 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);
}
});