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