diff --git a/html/css/app.css b/html/css/app.css index e06f5bd..5bf81be 100644 --- a/html/css/app.css +++ b/html/css/app.css @@ -411,6 +411,19 @@ a:hover { color: var(--accent-hover); text-decoration: underline; } border-color: var(--text-muted); } +/* Destructive action (discard draft, delete favorite, etc). Muted so it + doesn't overpower the primary CTA — the color is the signal. */ +.btn-danger { + background: transparent; + color: #b91c1c; + border-color: #fecaca; +} +.btn-danger:hover { + background: #fef2f2; + border-color: #f87171; + color: #991b1b; +} + .btn-ghost { background: transparent; color: var(--text-muted); @@ -598,6 +611,13 @@ table.dataTable.no-footer { border-bottom: none; } margin-top: 0.6em; } +/* Push destructive actions inside the modal footer to the far left so the + affirmative "Send" / "Keep editing" pair stays on the right and the user + can't fat-finger Discard on their way to Send. */ +.modal__actions .btn-danger { + margin-right: auto; +} + /* ------- Responsive ------------------------------------------------------ */ @media (max-width: 640px) { diff --git a/html/sendMessage.html b/html/sendMessage.html index c88bc8f..e087d66 100644 --- a/html/sendMessage.html +++ b/html/sendMessage.html @@ -81,6 +81,7 @@
+
@@ -92,6 +93,7 @@

Ready to send?

This message will be sent.

diff --git a/html/sendMessage.js b/html/sendMessage.js index aa33f55..b798bd2 100644 --- a/html/sendMessage.js +++ b/html/sendMessage.js @@ -243,6 +243,41 @@ function loadNewGroups(appName) { }); } +// ---- cancel / discard ----------------------------------------------------- + +// POSTs /jobs/cancel to drop the caller's in-progress "building" job for +// this bot. Idempotent server-side, so it's safe to call whether or not +// the user has already hit "Review & send" (which is what creates the +// building entry). Fires an unawaited request — clearing the local form +// doesn't depend on the response. +function requestServerCancel() { + fetch('/CollabCentral/' + CollabCentral.appName + '/jobs/cancel', { method: 'POST' }) + .catch(function (err) { console.error('Cancel request failed:', err); }); +} + +// Cancel button on the compose form. Confirms with the user (a stray +// click shouldn't nuke a half-written draft), tells the server to drop +// any in-progress building entry, then clears the local form. +function cancelMessage() { + var hasDraft = !!message.value() + || document.getElementById('uploadImage').files.length + || document.getElementById('uploadCSV').files.length + || document.querySelector('#favGroups').getSelectedOptions().length + || document.querySelector('#newGroups').getSelectedOptions().length + || document.getElementById('scheduledFor').value; + if (hasDraft && !window.confirm('Discard this message?')) return; + requestServerCancel(); + clearForm(); +} + +// "Discard" action from inside the review modal — same as Cancel but +// also closes the modal (which is open at this point). +function discardMessage() { + requestServerCancel(); + clearForm(); + closeModal(); +} + // ---- send-flow modal ------------------------------------------------------ function submit() { @@ -286,4 +321,11 @@ function clearForm() { var preview = document.getElementById('file-ip-1-preview'); preview.removeAttribute('src'); preview.style.display = 'none'; + // VirtualSelect exposes setValue([]) to clear a multi-select without + // reinitializing the widget. Guard for the case where init hasn't run + // yet (e.g. clearForm() called before the group lists finished loading). + var fav = document.querySelector('#favGroups'); + if (fav && typeof fav.setValue === 'function') fav.setValue([]); + var newG = document.querySelector('#newGroups'); + if (newG && typeof newG.setValue === 'function') newG.setValue([]); } diff --git a/index.js b/index.js index c33d675..f637b18 100644 --- a/index.js +++ b/index.js @@ -356,6 +356,29 @@ app.post('/CollabCentral/:app/jobs/:action', (req, res) => { delete jobs.building[buildingKey(req)]; saveConfig(jobs, './config/jobs.json') res.status(204).redirect("/CollabCentral/" + req.params.app + "/monitorJobs.html"); + } else if (req.params.action == "cancel") { + // Abandon the caller's in-progress "building" job for this bot. + // A building entry exists as soon as the user clicks "Review & + // send" (POST /jobs/edit) and lingers on the server until the + // user promotes it to running/scheduled — or until now, where + // this route just drops it on the floor. Idempotent: 200 whether + // or not a building entry actually existed for (personId, app), + // so the client can always fire this on "cancel" without needing + // to check state first. + logger("apiEndpoint(" + req.params.app + ")", "POST /jobs/cancel"); + var key = buildingKey(req); + var hadJob = !!jobs.building[key]; + if (hadJob) { + delete jobs.building[key]; + try { + saveConfig(jobs, './config/jobs.json'); + } catch (err) { + logger('apiEndpoint(' + req.params.app + ')', + 'jobs/cancel save failed: ' + (err && err.message || err)); + return res.status(500).send('Failed to cancel job.'); + } + } + return res.status(200).send({ cancelled: hadJob }); } else { res.status(404) } } else { res.status(401).send("You are not authorized.") }