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