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

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:
2026-08-07 22:35:36 -04:00
parent d81fdf4104
commit 9d982ab73f
3 changed files with 225 additions and 17 deletions
+93 -10
View File
@@ -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();