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>
331 lines
13 KiB
JavaScript
331 lines
13 KiB
JavaScript
// Page script for sendMessage.html. Bootstrapping (auth cookie check, /info
|
||
// fetch, header rendering, active-nav highlight) all live in js/app.js;
|
||
// this file just wires the compose form.
|
||
|
||
var modal = document.querySelector('.modal');
|
||
var overlay = document.querySelector('.overlay');
|
||
|
||
var message = new EasyMDE({
|
||
element: document.getElementById('my-text-area'),
|
||
toolbar: [
|
||
'bold', 'italic', '|',
|
||
'heading-1', 'heading-2', 'heading-3', '|',
|
||
'code', 'unordered-list', 'ordered-list', 'link', 'quote', '|',
|
||
'preview'
|
||
],
|
||
maxHeight: '260px',
|
||
});
|
||
|
||
// In-memory mirror of the server-side favorites list for this user + bot.
|
||
// Kept in sync with every /user/groups/add and /user/groups/remove response
|
||
// so the chip strip and the "already a favorite?" check stay authoritative
|
||
// without a round-trip.
|
||
var favoriteGroups = [];
|
||
|
||
CollabCentral.init(function (ctx) {
|
||
// User is authenticated and authorized. Reveal the form + populate the
|
||
// group selectors from the backend.
|
||
document.getElementById('formPanel').classList.remove('hidden');
|
||
loadFavoriteGroups(ctx.appName);
|
||
loadNewGroups(ctx.appName);
|
||
});
|
||
|
||
document.getElementById('sendMessage').onsubmit = function (event) {
|
||
event.preventDefault();
|
||
var appName = CollabCentral.appName;
|
||
var xhttp = new XMLHttpRequest();
|
||
|
||
xhttp.onreadystatechange = function () {
|
||
if (this.readyState === this.DONE && this.status === 200) {
|
||
var result = JSON.parse(xhttp.response);
|
||
var scheduled = document.getElementById('scheduledFor').value;
|
||
var text = 'This message will be sent to <strong>' + result.memberList.length + '</strong> recipient(s).<br><br>';
|
||
if (scheduled) {
|
||
text += 'Scheduled for ' + CollabCentral.escapeHtml(scheduled) + '.';
|
||
} else {
|
||
text += 'It will be sent immediately once you confirm.';
|
||
}
|
||
document.getElementById('modalMessage').innerHTML = text;
|
||
openModal();
|
||
}
|
||
};
|
||
|
||
var formData = new FormData();
|
||
formData.append('uploadImage', document.getElementById('uploadImage').files[0]);
|
||
formData.append('message', message.value());
|
||
formData.append('uploadCSV', document.getElementById('uploadCSV').files[0]);
|
||
formData.append('appName', appName);
|
||
|
||
var selectedGroups = [];
|
||
for (var group of document.querySelector('#favGroups').getSelectedOptions()) {
|
||
selectedGroups.push(group.value);
|
||
}
|
||
for (var group of document.querySelector('#newGroups').getSelectedOptions()) {
|
||
selectedGroups.push(group.value);
|
||
}
|
||
formData.append('groups', selectedGroups);
|
||
formData.append('scheduledFor', document.getElementById('scheduledFor').value);
|
||
|
||
xhttp.open('POST', '/CollabCentral/' + appName + '/jobs/edit');
|
||
xhttp.send(formData);
|
||
};
|
||
|
||
function showImagePreview(event) {
|
||
if (!event.target.files || !event.target.files.length) return;
|
||
var src = URL.createObjectURL(event.target.files[0]);
|
||
var preview = document.getElementById('file-ip-1-preview');
|
||
preview.src = src;
|
||
preview.style.display = 'block';
|
||
}
|
||
|
||
// ---- Favorites -----------------------------------------------------------
|
||
|
||
function loadFavoriteGroups(appName) {
|
||
var el = document.getElementById('favGroups');
|
||
fetch('/CollabCentral/' + appName + '/user/groups/list')
|
||
.then(function (res) { return res.json(); })
|
||
.then(function (groups) {
|
||
favoriteGroups = Array.isArray(groups) ? groups.slice() : [];
|
||
for (var i = 0; i < favoriteGroups.length; i++) {
|
||
var option = document.createElement('option');
|
||
option.text = favoriteGroups[i].name;
|
||
option.value = favoriteGroups[i].id;
|
||
el.add(option);
|
||
}
|
||
VirtualSelect.init({ ele: '#favGroups' });
|
||
renderFavoritesManager();
|
||
});
|
||
}
|
||
|
||
// Renders the chip strip below the favorites picker. Each chip has an
|
||
// × button that removes the group from favorites. The chip strip is
|
||
// intentionally separate from the VirtualSelect widget: the picker answers
|
||
// "which favorites am I sending to right now?", the chip strip answers
|
||
// "which groups do I want to remember for next time?".
|
||
function renderFavoritesManager() {
|
||
var el = document.getElementById('favGroupsManager');
|
||
if (!el) return;
|
||
el.innerHTML = '';
|
||
if (!favoriteGroups.length) {
|
||
el.classList.add('hidden');
|
||
return;
|
||
}
|
||
el.classList.remove('hidden');
|
||
for (var i = 0; i < favoriteGroups.length; i++) {
|
||
var g = favoriteGroups[i];
|
||
var chip = document.createElement('span');
|
||
chip.className = 'chip';
|
||
var label = document.createElement('span');
|
||
label.className = 'chip__label';
|
||
label.textContent = g.alias ? g.alias : g.name;
|
||
if (g.alias && g.name && g.alias !== g.name) {
|
||
chip.title = g.name;
|
||
}
|
||
var btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'chip__close';
|
||
btn.setAttribute('aria-label', 'Remove ' + (g.name || 'favorite'));
|
||
btn.textContent = '×';
|
||
btn.dataset.groupId = g.id;
|
||
btn.addEventListener('click', onRemoveFavorite);
|
||
chip.appendChild(label);
|
||
chip.appendChild(btn);
|
||
el.appendChild(chip);
|
||
}
|
||
}
|
||
|
||
function onRemoveFavorite(event) {
|
||
var groupId = event.currentTarget.dataset.groupId;
|
||
if (!groupId) return;
|
||
event.currentTarget.disabled = true;
|
||
fetch('/CollabCentral/' + CollabCentral.appName + '/user/groups/remove', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: groupId })
|
||
})
|
||
.then(function (res) {
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
return res.json();
|
||
})
|
||
.then(function (updatedFavorites) {
|
||
favoriteGroups = updatedFavorites || [];
|
||
renderFavoritesManager();
|
||
flashFavoritesStatus('Removed from favorites.');
|
||
})
|
||
.catch(function (err) {
|
||
console.error('Failed to remove favorite:', err);
|
||
flashFavoritesStatus('Could not remove favorite.', true);
|
||
event.currentTarget.disabled = false;
|
||
});
|
||
}
|
||
|
||
// Fires when the "Additional groups" multi-select changes. For each newly
|
||
// selected id that isn't already a favorite we POST /user/groups/add. The
|
||
// picker itself keeps the selection (so this send still uses the group);
|
||
// the group starts appearing in the Favorite Groups picker on the next
|
||
// page load, and immediately in the chip strip below.
|
||
function onNewGroupsChange(newlySelectedIds) {
|
||
if (!newlySelectedIds || !newlySelectedIds.length) return;
|
||
var newGroupsEl = document.getElementById('newGroups');
|
||
var pending = 0;
|
||
var successes = 0;
|
||
|
||
newlySelectedIds.forEach(function (id) {
|
||
if (favoriteGroups.some(function (g) { return g.id === id; })) return;
|
||
pending++;
|
||
fetch('/CollabCentral/' + CollabCentral.appName + '/user/groups/add', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: id })
|
||
})
|
||
.then(function (res) {
|
||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||
return res.json();
|
||
})
|
||
.then(function (updatedFavorites) {
|
||
favoriteGroups = updatedFavorites || favoriteGroups;
|
||
successes++;
|
||
renderFavoritesManager();
|
||
})
|
||
.catch(function (err) {
|
||
console.error('Failed to add favorite ' + id + ':', err);
|
||
flashFavoritesStatus('Could not save one or more favorites.', true);
|
||
})
|
||
.finally(function () {
|
||
pending--;
|
||
if (pending === 0 && successes > 0) {
|
||
flashFavoritesStatus(successes === 1
|
||
? 'Added to favorites.'
|
||
: 'Added ' + successes + ' to favorites.');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function flashFavoritesStatus(text, isError) {
|
||
var el = document.getElementById('favGroupsStatus');
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.classList.toggle('fieldStatus--error', !!isError);
|
||
clearTimeout(flashFavoritesStatus._t);
|
||
flashFavoritesStatus._t = setTimeout(function () {
|
||
el.textContent = '';
|
||
el.classList.remove('fieldStatus--error');
|
||
}, 4000);
|
||
}
|
||
|
||
function loadNewGroups(appName) {
|
||
var el = document.getElementById('newGroups');
|
||
fetch('/CollabCentral/' + appName + '/user/groups/find')
|
||
.then(function (res) { return res.json(); })
|
||
.then(function (groups) {
|
||
for (var i = 0; i < groups.length; i++) {
|
||
var option = document.createElement('option');
|
||
option.text = groups[i].displayName;
|
||
option.value = groups[i].id;
|
||
el.add(option);
|
||
}
|
||
VirtualSelect.init({ ele: '#newGroups' });
|
||
|
||
// VirtualSelect emits a standard 'change' event on the original
|
||
// <select>. We diff against the previous selection so we only
|
||
// fire /user/groups/add for the newly-picked ids, not the whole
|
||
// set on every change.
|
||
var previous = [];
|
||
el.addEventListener('change', function () {
|
||
var current = [];
|
||
var selected = el.getSelectedOptions();
|
||
for (var j = 0; j < selected.length; j++) current.push(selected[j].value);
|
||
var added = current.filter(function (id) { return previous.indexOf(id) === -1; });
|
||
previous = current;
|
||
if (added.length) onNewGroupsChange(added);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ---- 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() {
|
||
var appName = CollabCentral.appName;
|
||
var scheduled = document.getElementById('scheduledFor').value;
|
||
var url = scheduled
|
||
? '/CollabCentral/' + appName + '/jobs/schedule'
|
||
: '/CollabCentral/' + appName + '/jobs/runNow';
|
||
|
||
fetch(url, { method: 'POST', redirect: 'follow' })
|
||
.then(function (response) {
|
||
clearForm();
|
||
if (response.redirected) window.location.href = response.url;
|
||
})
|
||
.catch(function (err) {
|
||
console.error('Submit failed:', err);
|
||
});
|
||
|
||
closeModal();
|
||
}
|
||
|
||
function continueEditing() { closeModal(); }
|
||
|
||
function openModal() {
|
||
modal.classList.remove('hidden');
|
||
overlay.classList.remove('hidden');
|
||
}
|
||
|
||
function closeModal() {
|
||
modal.classList.add('hidden');
|
||
overlay.classList.add('hidden');
|
||
}
|
||
|
||
overlay.addEventListener('click', closeModal);
|
||
|
||
function clearForm() {
|
||
document.getElementById('uploadImage').value = '';
|
||
message.value('');
|
||
document.getElementById('uploadCSV').value = '';
|
||
document.getElementById('scheduledFor').value = '';
|
||
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([]);
|
||
}
|