Bulk status/close: enforce Workflow Designer rules (#21)
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 30s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m15s
Lint / Deploy (push) Successful in 2s
Lint / PHP (phpcs PSR-12) (push) Successful in 23s
Lint / JS (eslint) (push) Successful in 9s
Lint / PHP requirements (version + extensions) (push) Successful in 30s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m15s
Lint / Deploy (push) Successful in 2s
Bulk status changes previously bypassed the workflow entirely — the model carried an explicit "admin-only escape hatch" note — so bulk edit could drive tickets through transitions the designer forbids and skip comments the designer requires. BulkOperationsModel now applies the same rules as the single-ticket path: - Transitions absent from status_transitions are refused per ticket and reported with a reason, instead of being forced through. - requires_comment is checked up front across the whole selection, so a batch is rejected before any ticket is mutated rather than half-applied. - The reason is persisted as a comment on each ticket changed, matching what a single-ticket close records. - Tickets already in the target status are a no-op success, not a failure. requires_admin needs no extra check: api/bulk_operation.php already gates the endpoint on admin. Client: both bulk modals now collect a reason, the close path gets a real modal instead of a bare confirm, and per-ticket skip reasons surface in the result toast instead of a bare failure count.
This commit is contained in:
+93
-10
@@ -157,6 +157,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
case 'close-bulk-status-modal':
|
||||
closeBulkStatusModal();
|
||||
break;
|
||||
case 'perform-bulk-close':
|
||||
performBulkCloseAction();
|
||||
break;
|
||||
case 'close-bulk-close-modal':
|
||||
closeBulkCloseModal();
|
||||
break;
|
||||
case 'perform-bulk-delete':
|
||||
performBulkDelete();
|
||||
break;
|
||||
@@ -515,24 +521,59 @@ function bulkClose() {
|
||||
return;
|
||||
}
|
||||
|
||||
showConfirmModal(
|
||||
`Close ${ticketIds.length} Ticket(s)?`,
|
||||
'Are you sure you want to close these tickets?',
|
||||
'warning',
|
||||
() => performBulkCloseAction(ticketIds)
|
||||
);
|
||||
// Closing needs a reason: the default workflow marks every → Closed transition
|
||||
// requires_comment, so collect it here instead of failing server-side.
|
||||
const modalHtml = `
|
||||
<div class="lt-modal-overlay" id="bulkCloseModal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="bulkCloseModalTitle">
|
||||
<div class="lt-modal">
|
||||
<div class="lt-modal-header" style="color:var(--terminal-amber)">
|
||||
<span class="lt-modal-title" id="bulkCloseModalTitle">[ ! ] Close ${ticketIds.length} Ticket(s)</span>
|
||||
<button class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body">
|
||||
<label for="bulkCloseComment">Close Reason:</label>
|
||||
<textarea id="bulkCloseComment" class="lt-input lt-w-full" rows="3"
|
||||
placeholder="Why are these tickets being closed?…"
|
||||
style="resize:vertical;font-family:inherit;font-size:0.8rem"
|
||||
aria-label="Reason for closing the tickets"></textarea>
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
|
||||
Posted as a comment on every ticket closed. Tickets whose workflow
|
||||
forbids closing from their current status are skipped.
|
||||
</p>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button data-action="perform-bulk-close" class="lt-btn lt-btn-primary">CLOSE TICKETS</button>
|
||||
<button data-action="close-bulk-close-modal" class="lt-btn lt-btn-ghost">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
lt.modal.open('bulkCloseModal');
|
||||
}
|
||||
|
||||
function closeBulkCloseModal() {
|
||||
lt.modal.close('bulkCloseModal');
|
||||
const modal = document.getElementById('bulkCloseModal');
|
||||
if (modal) setTimeout(() => modal.remove(), 300);
|
||||
}
|
||||
|
||||
function performBulkCloseAction(ticketIds) {
|
||||
ticketIds = ticketIds || getSelectedTicketIds();
|
||||
const commentEl = document.getElementById('bulkCloseComment');
|
||||
const comment = commentEl ? commentEl.value.trim() : '';
|
||||
|
||||
lt.api.post('/api/bulk_operation.php', {
|
||||
operation_type: 'bulk_close',
|
||||
ticket_ids: ticketIds
|
||||
ticket_ids: ticketIds,
|
||||
parameters: { comment: comment }
|
||||
})
|
||||
.then(data => {
|
||||
closeBulkCloseModal();
|
||||
if (data.success) {
|
||||
if (data.failed > 0) {
|
||||
lt.toast.warning(`Bulk close: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
||||
lt.toast.warning(bulkResultMessage('Bulk close', data), 6000);
|
||||
} else {
|
||||
lt.toast.success(`Successfully closed ${data.processed} ticket(s)`, 4000);
|
||||
}
|
||||
@@ -542,6 +583,14 @@ function performBulkCloseAction(ticketIds) {
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Missing required comment — keep the modal open so it can be entered.
|
||||
if (error && error.data && error.data.requires_comment) {
|
||||
lt.toast.warning(error.data.error || 'A close reason is required', 6000);
|
||||
const ta = document.getElementById('bulkCloseComment');
|
||||
if (ta) ta.focus();
|
||||
return;
|
||||
}
|
||||
closeBulkCloseModal();
|
||||
lt.toast.error('Bulk close failed: ' + error.message, 5000);
|
||||
});
|
||||
}
|
||||
@@ -777,6 +826,15 @@ function showBulkStatusModal() {
|
||||
<option value="">Select Status...</option>
|
||||
${(window.TICKET_STATUSES || ['Open','Pending','In Progress','Closed']).map(s => `<option value="${s}">${s}</option>`).join('')}
|
||||
</select>
|
||||
<label for="bulkStatusComment" style="margin-top:0.75rem">Reason / Comment:</label>
|
||||
<textarea id="bulkStatusComment" class="lt-input lt-w-full" rows="3"
|
||||
placeholder="Reason for the status change…"
|
||||
style="resize:vertical;font-family:inherit;font-size:0.8rem"
|
||||
aria-label="Reason for the bulk status change"></textarea>
|
||||
<p class="lt-text-xs lt-text-muted" style="margin-top:0.35rem">
|
||||
Required for transitions the Workflow Designer marks as needing a comment
|
||||
(e.g. closing a ticket). Posted as a comment on every ticket changed.
|
||||
</p>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button data-action="perform-bulk-status" class="lt-btn lt-btn-primary">UPDATE</button>
|
||||
@@ -807,16 +865,19 @@ function performBulkStatusChange() {
|
||||
return;
|
||||
}
|
||||
|
||||
const commentEl = document.getElementById('bulkStatusComment');
|
||||
const comment = commentEl ? commentEl.value.trim() : '';
|
||||
|
||||
lt.api.post('/api/bulk_operation.php', {
|
||||
operation_type: 'bulk_status',
|
||||
ticket_ids: ticketIds,
|
||||
parameters: { status: status }
|
||||
parameters: { status: status, comment: comment }
|
||||
})
|
||||
.then(data => {
|
||||
closeBulkStatusModal();
|
||||
if (data.success) {
|
||||
if (data.failed > 0) {
|
||||
lt.toast.warning(`Status update: ${data.processed} succeeded, ${data.failed} failed`, 5000);
|
||||
lt.toast.warning(bulkResultMessage('Status update', data), 6000);
|
||||
} else {
|
||||
lt.toast.success(`Successfully updated status for ${data.processed} ticket(s)`, 4000);
|
||||
}
|
||||
@@ -826,10 +887,32 @@ function performBulkStatusChange() {
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// Workflow needs a comment for at least one selected ticket — keep the
|
||||
// modal open so the reason can be typed in without re-selecting.
|
||||
if (error && error.data && error.data.requires_comment) {
|
||||
lt.toast.warning(error.data.error || 'A comment is required for this status change', 6000);
|
||||
const ta = document.getElementById('bulkStatusComment');
|
||||
if (ta) ta.focus();
|
||||
return;
|
||||
}
|
||||
closeBulkStatusModal();
|
||||
lt.toast.error('Bulk status change failed: ' + error.message, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a result message for a partially-successful bulk operation, surfacing the
|
||||
* per-ticket reasons (e.g. "transition not allowed") instead of a bare count.
|
||||
*/
|
||||
function bulkResultMessage(label, data) {
|
||||
let msg = `${label}: ${data.processed} succeeded, ${data.failed} failed`;
|
||||
if (Array.isArray(data.errors) && data.errors.length) {
|
||||
msg += ' — ' + data.errors.slice(0, 3).join('; ');
|
||||
if (data.errors.length > 3) msg += ` (+${data.errors.length - 3} more)`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Bulk Delete
|
||||
function showBulkDeleteModal() {
|
||||
const ticketIds = getSelectedTicketIds();
|
||||
|
||||
Reference in New Issue
Block a user