fix: DockerSecretsResolver - don't normalize absolute paths like /var/www/html/...
Some checks failed
Deploy Application / deploy (push) Has been cancelled
Some checks failed
Deploy Application / deploy (push) Has been cancelled
This commit is contained in:
468
resources/js/modules/admin/bulk-operations.js
Normal file
468
resources/js/modules/admin/bulk-operations.js
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* 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 = `
|
||||
<div class="bulk-confirm-dialog__overlay"></div>
|
||||
<div class="bulk-confirm-dialog__content">
|
||||
<h3>${title}</h3>
|
||||
<p>${message}</p>
|
||||
<div class="bulk-confirm-dialog__actions">
|
||||
<button class="btn btn--secondary" data-confirm-cancel>Cancel</button>
|
||||
<button class="btn btn--danger" data-confirm-ok>Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 =>
|
||||
`<button type="button" class="bulk-tag-suggestion" data-tag="${tag}">${tag}</button>`
|
||||
).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);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user