mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-22 14:51:09 -05:00
- 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.
306 lines
9.7 KiB
JavaScript
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);
|
|
}
|
|
});
|