/** * Bulk Operations Module for Admin Tables * * Handles bulk selection and actions for admin data tables */ export class BulkOperations { constructor(tableElement) { this.table = tableElement; this.resource = tableElement.dataset.resource; this.bulkActions = JSON.parse(tableElement.dataset.bulkActions || '[]'); this.selectedIds = new Set(); this.toolbar = document.querySelector(`[data-bulk-toolbar="${this.resource}"]`); this.countElement = this.toolbar?.querySelector('[data-bulk-count]'); this.buttonsContainer = this.toolbar?.querySelector('[data-bulk-buttons]'); this.init(); } init() { if (!this.table.dataset.bulkOperations || this.table.dataset.bulkOperations !== 'true') { return; // Bulk operations not enabled for this table } this.setupCheckboxes(); this.setupSelectAll(); this.setupBulkActions(); this.updateToolbar(); } setupCheckboxes() { const checkboxes = this.table.querySelectorAll('.bulk-select-item'); checkboxes.forEach(checkbox => { checkbox.addEventListener('change', (e) => { const id = e.target.dataset.bulkItemId; if (e.target.checked) { this.selectedIds.add(id); } else { this.selectedIds.delete(id); } this.updateToolbar(); this.updateSelectAllState(); }); }); } setupSelectAll() { const selectAllCheckbox = this.table.querySelector('[data-bulk-select-all]'); if (!selectAllCheckbox) return; selectAllCheckbox.addEventListener('change', (e) => { const checked = e.target.checked; const checkboxes = this.table.querySelectorAll('.bulk-select-item'); checkboxes.forEach(checkbox => { checkbox.checked = checked; const id = checkbox.dataset.bulkItemId; if (checked) { this.selectedIds.add(id); } else { this.selectedIds.delete(id); } }); this.updateToolbar(); }); } setupBulkActions() { if (!this.buttonsContainer || this.bulkActions.length === 0) { return; } // Clear existing buttons this.buttonsContainer.innerHTML = ''; // Add buttons for each bulk action this.bulkActions.forEach(action => { const button = document.createElement('button'); button.className = `btn btn--${action.style || 'secondary'} btn--sm`; button.textContent = action.label; button.dataset.bulkAction = action.action; button.dataset.bulkMethod = action.method || 'POST'; if (action.confirm) { button.dataset.bulkConfirm = action.confirm; } button.addEventListener('click', (e) => { e.preventDefault(); this.executeBulkAction(action); }); this.buttonsContainer.appendChild(button); }); } async executeBulkAction(action) { if (this.selectedIds.size === 0) { return; } // Handle special actions that need custom modals if (action.action === 'tag' && action.confirm === false) { return this.showBulkTagModal(action); } // Show confirmation dialog if needed if (action.confirm) { const confirmed = await this.showConfirmationDialog( action.confirm, `This will affect ${this.selectedIds.size} item(s).` ); if (!confirmed) { return; } } try { // Show loading state this.setLoadingState(true); // Optimistic update: hide selected rows immediately const selectedRows = Array.from(this.selectedIds).map(id => { return this.table.querySelector(`tr[data-id="${id}"]`); }).filter(Boolean); const rollbackRows = selectedRows.map(row => { const originalDisplay = row.style.display; row.style.display = 'none'; row.classList.add('optimistic-delete'); return () => { row.style.display = originalDisplay; row.classList.remove('optimistic-delete'); }; }); const ids = Array.from(this.selectedIds); const method = action.method || 'POST'; // Map action names to endpoints const endpointMap = { 'assets': { 'delete': '/api/v1/assets/bulk/delete', 'tag': '/api/v1/assets/bulk/tags', }, 'contents': { 'delete': '/admin/api/contents/bulk/delete', 'publish': '/admin/api/contents/bulk/publish', }, }; const endpoint = action.endpoint || endpointMap[this.resource]?.[action.action] || `/admin/api/${this.resource}/bulk/${action.action}`; // Use safeFetch if available, otherwise regular fetch const fetchFn = window.safeFetch || (async (url, options) => { const response = await fetch(url, options); if (!response.ok) { const errorData = await response.json().catch(() => ({ error: 'Unknown error' })); throw new Error(errorData.error || `HTTP ${response.status}`); } return await response.json(); }); // Prepare request body let requestBody = { ids: Array.from(this.selectedIds) }; // Add tags if this is a tag action if (action.action === 'tag' && action.tags) { requestBody.tags = action.tags; } const result = await fetchFn(endpoint, { method: method, headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', }, body: JSON.stringify(requestBody), }); // Show success message const successCount = result.total_deleted || result.total_published || result.total_tagged || ids.length; const actionLabel = action.action === 'tag' ? 'tagged' : action.label.toLowerCase(); this.showToast(`Successfully ${actionLabel} ${successCount} item(s)`, 'success'); // Clear selection this.clearSelection(); // Reload table data if table has reload method const tableInstance = this.table.adminDataTable; if (tableInstance && typeof tableInstance.loadData === 'function') { tableInstance.loadData(); } else { // Fallback: reload page window.location.reload(); } } catch (error) { console.error('Bulk action failed:', error); // Rollback optimistic updates if (window.rollbackRows) { window.rollbackRows.forEach(rollback => rollback()); } // Show error with retry option const errorMessage = error.message || 'Unknown error occurred'; this.showToast(`Failed to ${action.label.toLowerCase()}: ${errorMessage}`, 'error'); // Show retry button if (this.toolbar) { const retryButton = document.createElement('button'); retryButton.className = 'retry-button'; retryButton.textContent = 'Retry'; retryButton.onclick = () => { retryButton.remove(); this.executeBulkAction(action); }; this.buttonsContainer?.appendChild(retryButton); } } finally { this.setLoadingState(false); } } updateToolbar() { if (!this.toolbar) return; const count = this.selectedIds.size; if (count > 0) { this.toolbar.style.display = 'flex'; if (this.countElement) { this.countElement.textContent = count; } } else { this.toolbar.style.display = 'none'; } } updateSelectAllState() { const selectAllCheckbox = this.table.querySelector('[data-bulk-select-all]'); if (!selectAllCheckbox) return; const checkboxes = this.table.querySelectorAll('.bulk-select-item'); const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length; if (checkedCount === 0) { selectAllCheckbox.checked = false; selectAllCheckbox.indeterminate = false; } else if (checkedCount === checkboxes.length) { selectAllCheckbox.checked = true; selectAllCheckbox.indeterminate = false; } else { selectAllCheckbox.checked = false; selectAllCheckbox.indeterminate = true; } } clearSelection() { this.selectedIds.clear(); const checkboxes = this.table.querySelectorAll('.bulk-select-item'); checkboxes.forEach(checkbox => { checkbox.checked = false; }); const selectAllCheckbox = this.table.querySelector('[data-bulk-select-all]'); if (selectAllCheckbox) { selectAllCheckbox.checked = false; selectAllCheckbox.indeterminate = false; } this.updateToolbar(); } setLoadingState(loading) { const buttons = this.buttonsContainer?.querySelectorAll('button'); if (buttons) { buttons.forEach(button => { button.disabled = loading; if (loading) { button.dataset.originalText = button.textContent; button.textContent = 'Processing...'; } else { button.textContent = button.dataset.originalText || button.textContent; } }); } } async showConfirmationDialog(title, message) { return new Promise((resolve) => { const dialog = document.createElement('div'); dialog.className = 'bulk-confirm-dialog'; dialog.innerHTML = `

${title}

${message}

`; document.body.appendChild(dialog); const cleanup = () => { document.body.removeChild(dialog); }; dialog.querySelector('[data-confirm-ok]').addEventListener('click', () => { cleanup(); resolve(true); }); dialog.querySelector('[data-confirm-cancel]').addEventListener('click', () => { cleanup(); resolve(false); }); dialog.querySelector('.bulk-confirm-dialog__overlay').addEventListener('click', () => { cleanup(); resolve(false); }); }); } showToast(message, type = 'info') { // Use existing toast system if available if (window.Toast) { window.Toast[type](message); } else { // Fallback: simple alert alert(message); } } async showBulkTagModal(action) { // Create or get modal let modal = document.getElementById('bulk-tag-modal'); if (!modal) { // Load modal HTML (should be included in page) modal = document.createElement('div'); modal.id = 'bulk-tag-modal'; modal.innerHTML = await fetch('/admin/templates/bulk-tag-modal').then(r => r.text()).catch(() => ''); document.body.appendChild(modal); } // Update count const countElement = modal.querySelector('#bulk-tag-count'); if (countElement) { countElement.textContent = this.selectedIds.size; } // Show modal modal.style.display = 'block'; // Setup tag input with autocomplete const tagInput = modal.querySelector('#bulk-tag-input'); const suggestionsContainer = modal.querySelector('#bulk-tag-suggestions'); const suggestionsList = modal.querySelector('.bulk-tag-suggestions-list'); let tagSuggestions = []; // Load tag suggestions const loadSuggestions = async (search = '') => { try { const response = await fetch(`/api/v1/tags/suggestions${search ? '?search=' + encodeURIComponent(search) : ''}`); const data = await response.json(); tagSuggestions = data.tags || []; if (tagSuggestions.length > 0) { suggestionsContainer.style.display = 'block'; suggestionsList.innerHTML = tagSuggestions.slice(0, 10).map(tag => `` ).join(''); // Add click handlers to suggestions suggestionsList.querySelectorAll('.bulk-tag-suggestion').forEach(btn => { btn.addEventListener('click', () => { const tag = btn.dataset.tag; const currentTags = tagInput.value.split(',').map(t => t.trim()).filter(Boolean); if (!currentTags.includes(tag)) { currentTags.push(tag); tagInput.value = currentTags.join(', '); } }); }); } else { suggestionsContainer.style.display = 'none'; } } catch (error) { console.error('Failed to load tag suggestions:', error); } }; // Load initial suggestions await loadSuggestions(); // Setup input listener for suggestions if (tagInput) { let debounceTimer; tagInput.addEventListener('input', (e) => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { const search = e.target.value.split(',').pop().trim(); if (search.length > 1) { loadSuggestions(search); } else { loadSuggestions(); } }, 300); }); } // Setup submit handler const submitButton = modal.querySelector('#bulk-tag-submit'); const closeModal = () => { modal.style.display = 'none'; if (tagInput) tagInput.value = ''; suggestionsContainer.style.display = 'none'; }; submitButton?.addEventListener('click', async () => { const tagsInput = tagInput?.value.trim(); if (!tagsInput) { this.showToast('Please enter at least one tag', 'error'); return; } const tags = tagsInput.split(',').map(t => t.trim()).filter(Boolean); // Execute tag action with tags const tagAction = { ...action, tags }; closeModal(); await this.executeBulkAction(tagAction); }); // Setup close handlers modal.querySelectorAll('[data-close-modal]').forEach(el => { el.addEventListener('click', closeModal); }); } } // Auto-initialize bulk operations for tables with bulk operations enabled document.addEventListener('DOMContentLoaded', () => { // Initialize immediately document.querySelectorAll('[data-bulk-operations="true"]').forEach(table => { new BulkOperations(table); }); // Also initialize after a short delay to catch dynamically loaded tables setTimeout(() => { document.querySelectorAll('[data-bulk-operations="true"]').forEach(table => { // Check if already initialized if (!table.dataset.bulkInitialized) { table.dataset.bulkInitialized = 'true'; new BulkOperations(table); } }); }, 100); });