collabcentral/html/sendMessage.js
Joseph B. McQueen 3816880801 Make favorites explicit: kill auto-save on Additional Groups picks
Auto-saving every Additional-Groups pick into favorites made the
list a running audit log of "every group I've ever touched" instead
of a curated set of go-to groups. Since most messages target a
different set of groups (one office one week, three campuses the
next), the auto-save polluted favorites for every user immediately.

Now:
- The Additional Groups picker is per-message only. Changing it no
  longer fires /user/groups/add.
- A new "Save selection to favorites" button under the Additional
  picker deliberately promotes whatever's currently selected there
  into the user's favorites. Explicit action, no surprises.
- On save, the just-promoted groups are moved from the Additional
  picker into the Favorites picker so this message's recipient set
  survives untouched, but the UI is visually consolidated to one
  place (avoids the "same group ticked in two pickers" confusion).
- The favorites VirtualSelect gets its options list rebuilt via
  setOptions() so the just-added groups are immediately pickable
  without a page reload; the existing selection is captured first
  because setOptions can reset it.
- Hint text updated to describe the new intent on both pickers.

CSS: added .fieldActions for a right-aligned button row under a
picker (sits between the picker and its hint).

Backend untouched — /user/groups/{add,remove,list} were already
per-id and the semantics still fit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 10:34:19 -04:00

385 lines
16 KiB
JavaScript
Raw Permalink 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;
});
}
// Explicit "Save selection to favorites" action. Reads whatever is
// currently selected in the "Additional groups" picker, POSTs
// /user/groups/add for each id that isn't already a favorite, and on
// success rewires the widget state so the newly-saved groups move from
// the Additional picker into the Favorite picker without losing this
// message's recipient list.
//
// Kept explicit (button-driven) rather than firing on every change so a
// one-off pick — the common case, since every message tends to target a
// different set of groups — doesn't pollute favorites forever.
function saveSelectionToFavorites() {
var newGroupsEl = document.getElementById('newGroups');
var favGroupsEl = document.getElementById('favGroups');
var btn = document.getElementById('saveFavoritesBtn');
if (!newGroupsEl) return;
var selectedOptions = newGroupsEl.getSelectedOptions() || [];
var candidates = [];
for (var i = 0; i < selectedOptions.length; i++) {
var id = selectedOptions[i].value;
if (favoriteGroups.some(function (g) { return g.id === id; })) continue;
candidates.push({ id: id, name: selectedOptions[i].label || selectedOptions[i].text });
}
if (!candidates.length) {
flashFavoritesStatus('Nothing new to save.');
return;
}
if (btn) btn.disabled = true;
var latestFavorites = favoriteGroups;
var successIds = [];
var pending = candidates.length;
candidates.forEach(function (c) {
fetch('/CollabCentral/' + CollabCentral.appName + '/user/groups/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: c.id })
})
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(function (updatedFavorites) {
if (Array.isArray(updatedFavorites)) latestFavorites = updatedFavorites;
successIds.push(c.id);
})
.catch(function (err) {
console.error('Failed to add favorite ' + c.id + ':', err);
flashFavoritesStatus('Could not save one or more favorites.', true);
})
.finally(function () {
pending--;
if (pending > 0) return;
if (btn) btn.disabled = false;
if (!successIds.length) return;
// Capture the current favorite selection BEFORE
// rebuilding the options list — setOptions can clear
// the selection depending on the VirtualSelect
// internals, and we want this message's existing
// favorite picks to survive.
var currentFavIds = [];
if (favGroupsEl && typeof favGroupsEl.getSelectedOptions === 'function') {
var favSel = favGroupsEl.getSelectedOptions() || [];
for (var j = 0; j < favSel.length; j++) currentFavIds.push(favSel[j].value);
}
// Adopt the freshest server view of favorites, then
// rebuild the favorites VirtualSelect options list so
// the just-saved groups become pickable there.
favoriteGroups = latestFavorites;
renderFavoritesManager();
if (favGroupsEl && typeof favGroupsEl.setOptions === 'function') {
favGroupsEl.setOptions(favoriteGroups.map(function (g) {
return { label: g.name, value: g.id };
}));
}
// Preserve this message's recipient set: move the
// saved ids from the Additional picker into the
// Favorite picker. Users can still deselect either
// way manually, but the default is "you saved these,
// they're still going to those groups".
if (favGroupsEl && typeof favGroupsEl.setValue === 'function') {
for (var k = 0; k < successIds.length; k++) {
if (currentFavIds.indexOf(successIds[k]) === -1) currentFavIds.push(successIds[k]);
}
favGroupsEl.setValue(currentFavIds);
}
if (typeof newGroupsEl.setValue === 'function') {
var remaining = [];
var addSel = newGroupsEl.getSelectedOptions() || [];
for (var m = 0; m < addSel.length; m++) {
var mid = addSel[m].value;
if (successIds.indexOf(mid) === -1) remaining.push(mid);
}
newGroupsEl.setValue(remaining);
}
flashFavoritesStatus(successIds.length === 1
? 'Added to favorites.'
: 'Added ' + successIds.length + ' 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' });
// Additional groups are per-message only — no auto-save on
// change. Users promote picks to favorites via the explicit
// "Save selection to favorites" button (saveSelectionToFavorites).
});
}
// ---- 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([]);
}