mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-23 00:31:08 -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);
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user