collabcentral/html/sendMessage.js
Joseph B. McQueen 224e853368 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>
2026-07-01 20:32:50 -04:00

331 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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([]);
}