collabcentral/html/sendMessage.js
Joseph B. McQueen 524b443dce Make favorite groups editable from the compose page
Selecting a group in "Additional groups" now auto-saves it to the
caller's favorites, and each favorite gets a × affordance for one-click
removal. Favorites still live where they always have —
config.webex.bot[app].authorized[personId].groups — so nothing changes
for existing installations.

Backend
- New POST /CollabCentral/:app/user/groups/add. Body { id }. Validates
  the caller is authorized for :app, validates :id resolves to a real
  Webex group by looking it up in the cached org-wide group list
  (blocks arbitrary strings from being stuffed into config.json),
  dedups against the existing favorites array, writes config.json via
  saveConfig, and returns the updated array.
- New POST /CollabCentral/:app/user/groups/remove. Body { id }.
  Filters that id out of the caller's favorites, writes only when
  something actually changed (a remove of an unknown id no-ops instead
  of rewriting config.json), and returns the updated array.
- Both endpoints are safe against concurrent writes: index.js is
  single-process and node is single-threaded, so read/mutate/write
  runs atomically per request.
- Both log a compact audit line (last-8 of personId + group name) so
  operators can see who is curating what.

Frontend (sendMessage.html + .js + app.css)
- New "Manage favorites" chip strip renders directly under the
  Favorite Groups picker. Each favorite becomes a pill (uses .alias
  when set, otherwise .name; long labels truncate with ellipsis).
  Clicking × on a pill removes that favorite server-side and updates
  the strip locally.
- Additional Groups picker wires a 'change' listener that diffs the
  current selection against the previous one and only fires
  /user/groups/add for newly-picked ids (never re-fires on the
  reselect side of a deselect+reselect, never spams the API with the
  full selection on every keystroke).
- Client keeps favoriteGroups mirrored to every API response so the
  "already a favorite?" dedup check is a pure in-memory lookup — no
  wasted round trips when the user re-picks a group they already
  favorited.
- Transient status message ("Added to favorites." / "Removed from
  favorites." / error variants) fades under the field label; sticks
  around ~4s.
- New shared styles: .fieldLabelRow (label + inline status),
  .fieldStatus (with --error variant), .chipStrip, .chip,
  .chip__label, .chip__close (with hover/focus states).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 20:25:15 -04:00

289 lines
11 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);
});
});
}
// ---- 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';
}