From 224e853368bcd29bcaa4dd3122e3465c81987f28 Mon Sep 17 00:00:00 2001 From: "Joseph B. McQueen" Date: Wed, 1 Jul 2026 20:32:50 -0400 Subject: [PATCH] Add a cancel button to the compose page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now abandon a draft they don't want to send. Two entry points, one shared server action. Backend - New action on the existing POST /CollabCentral/:app/jobs/:action route: /jobs/cancel. Deletes jobs.building[::] if present, saves jobs.json, returns 200 with { cancelled: true }. - Idempotent: if there was no building entry (client fires this on every cancel click regardless of state), the endpoint no-ops with { cancelled: false } and never touches jobs.json. That keeps disk writes tied to real state changes instead of every click. - Wrapped saveConfig in try/catch so a disk-full or permission failure returns a clean 500 instead of crashing the request. Frontend - New "Cancel" button in the compose form's action row, sits next to "Review & send" as a secondary action. - cancelMessage() checks whether the form has anything in it (text, image, CSV, groups, schedule) and only prompts window.confirm() if there's actually a draft to lose. Blank-form clicks skip the prompt so the button behaves as expected on first load. - New "Discard" action on the review modal for "wait, actually no" decisions after clicking Review. Positioned on the far left of the modal footer with margin-right: auto so it's visually separated from the affirmative "Keep editing" / "Send" pair — a stray click on the way to Send lands on Keep editing, not Discard. - Extended clearForm() to also clear both VirtualSelect group pickers via setValue([]) (previously only text/files/schedule got reset, leaving stale group selections after a cancel or send). - New .btn-danger style: muted red text on a transparent background, filled-red hover state. Used for the modal Discard button and available for future destructive UI. Not covered (follow-up if wanted): the preview DM the bot sends to the sender during /jobs/edit isn't tracked in the building job, so Cancel doesn't retract that Webex message. Adding server-side tracking of the preview message id would let cancel call DELETE /v1/messages/ to clean it up. Co-authored-by: Cursor --- html/css/app.css | 20 ++++++++++++++++++++ html/sendMessage.html | 2 ++ html/sendMessage.js | 42 ++++++++++++++++++++++++++++++++++++++++++ index.js | 23 +++++++++++++++++++++++ 4 files changed, 87 insertions(+) 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.") }