Add a cancel button to the compose page
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[<personId>::<appName>]
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/<id> to clean it up.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
524b443dce
commit
224e853368
4 changed files with 87 additions and 0 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@
|
|||
</div>
|
||||
|
||||
<div class="formActions">
|
||||
<button id="cancel" type="button" class="btn btn-secondary" onclick="cancelMessage()">Cancel</button>
|
||||
<button id="submit" type="submit" class="btn btn-primary">Review & send</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
@ -92,6 +93,7 @@
|
|||
<h3>Ready to send?</h3>
|
||||
<p id="modalMessage">This message will be sent.</p>
|
||||
<div class="modal__actions">
|
||||
<button class="btn btn-danger" onclick="discardMessage()">Discard</button>
|
||||
<button class="btn btn-secondary" onclick="continueEditing()">Keep editing</button>
|
||||
<button class="btn btn-primary" onclick="submit()">Send</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
}
|
||||
|
|
|
|||
23
index.js
23
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.") }
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue