diff --git a/agent/css/ticket.css b/agent/css/ticket.css index 2c8d5171c..22f9923f4 100644 --- a/agent/css/ticket.css +++ b/agent/css/ticket.css @@ -72,3 +72,18 @@ min-width: 45%; } } + +/* + * Bootstrap cancels hover on any .btn following a .btn-check (twbs #31149 / PR #37026): + * a checked outline toggle and an unchecked one looked identical while hovered, so you + * could not read the control's state without moving the pointer away. Correct, but the + * selector also catches the UNCHECKED buttons, and with nothing preselected here that + * leaves the whole strip with no feedback at all. + * + * Give the unchecked ones a NEUTRAL tint - not the variant fill, which is what checked + * looks like and would reintroduce exactly the ambiguity #31149 was about. Upstream's + * color and border-color still apply, so only the background moves. + */ +#replyTypePicker .btn-check:not(:checked) + .btn:hover { + background-color: var(--bs-secondary-bg); +} diff --git a/agent/post/ticket.php b/agent/post/ticket.php index 5bad5f6f6..e573b303e 100644 --- a/agent/post/ticket.php +++ b/agent/post/ticket.php @@ -1865,9 +1865,10 @@ if (isset($_POST['add_ticket_reply'])) { // Defaults $send_email = 0; $ticket_reply_id = 0; - if ($_POST['public_reply_type'] == 1 ){ + $public_reply_type = intval($_POST['public_reply_type'] ?? 0); + if ($public_reply_type == 1) { $ticket_reply_type = 'Public'; - } elseif ($_POST['public_reply_type'] == 2 ) { + } elseif ($public_reply_type == 2) { $ticket_reply_type = 'Public'; $send_email = 1; } else { diff --git a/agent/ticket.php b/agent/ticket.php index e412f0104..7ebbe3a8e 100644 --- a/agent/ticket.php +++ b/agent/ticket.php @@ -715,11 +715,15 @@ if (isset($_GET['ticket_id'])) {
- -
+ +
- + @@ -727,103 +731,114 @@ if (isset($_GET['ticket_id'])) {
- +
- /* - * Canned responses offered on this ticket: the ones tied to its - * category, plus the general ones that are offered everywhere. - * Names only - the body is fetched when one is picked, so a - * shelf of long responses does not ride along with every ticket. - */ - $sql_canned_responses = mysqli_query($mysqli, "SELECT canned_response_id, canned_response_name, canned_response_category_id - FROM canned_responses - WHERE canned_response_archived_at IS NULL - AND (canned_response_category_id = 0 OR canned_response_category_id = $ticket_category) - ORDER BY canned_response_name ASC"); + + while ($canned_row = mysqli_fetch_assoc($sql_canned_responses)) { + if (intval($canned_row['canned_response_category_id']) === 0) { + $canned_responses_general[] = $canned_row; + } else { + $canned_responses_for_category[] = $canned_row; + } + } -
- + + + + + + + - - - - - - + + + + + + - - - -
+ +
- + -
- -
- -
- -
- -
-
-
- - +
+
-
- -
-
- -
- - - - - +
+ +
+ +
+
+
+ + +
+
+ + +
+
+ +
+ + + + + +
+
+
+ +
+
+ + +
-
-
-
@@ -1510,6 +1525,53 @@ require_once "../includes/footer.php"; }); } + // Reply composer - the type buttons above are the entry point. The composer stays + // collapsed until one is picked, so the conversation is not pushed down the page and + // a public reply is never the default. + const replyComposer = document.getElementById('replyComposer'); + if (replyComposer) { + const replyComposerCollapse = bootstrap.Collapse.getOrCreateInstance(replyComposer, { toggle: false }); + const replyTypes = document.querySelectorAll('input[name="public_reply_type"]'); + + // Only ever opens. Switching Internal -> Public mid-draft must not throw the draft away. + replyTypes.forEach(radio => radio.addEventListener('change', () => replyComposerCollapse.show())); + + replyComposer.addEventListener('shown.bs.collapse', function () { + // TinyMCE's autoresize plugin measured the editor while its container was + // display:none, so it sized to nothing. Re-measure now the box is real. + const editor = window.tinymce ? tinymce.get('ticket_reply') : null; + + if (editor) { + editor.execCommand('mceAutoResize'); + editor.focus(); + } else { + const textarea = document.getElementById('ticket_reply'); + if (textarea) textarea.focus(); + } + }); + + const cancelReply = document.getElementById('cancelReply'); + if (cancelReply) { + cancelReply.addEventListener('click', function () { + replyComposerCollapse.hide(); + replyTypes.forEach(radio => { radio.checked = false; }); + }); + } + + // The status select is required and lives inside the collapse. A required control in a + // display:none container blocks submission with nothing on screen to explain it, and the + // form can still be submitted with Enter from a focused reply-type button, so refuse + // outright while the composer is shut. + const replyForm = replyComposer.closest('form'); + if (replyForm) { + replyForm.addEventListener('submit', function (e) { + if (!replyComposer.classList.contains('show')) { + e.preventDefault(); + } + }); + } + } + // Conversation filter - show everything, only what the client can see, or only internal notes const replyFilter = document.getElementById('replyFilter'); if (replyFilter) {