/** * 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 = `
${message}