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>
This commit is contained in:
parent
452673b9be
commit
524b443dce
4 changed files with 320 additions and 4 deletions
|
|
@ -234,6 +234,101 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
|
|||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
/* Label row: puts the label and an inline transient status message on the
|
||||
same line without disturbing the vertical rhythm of surrounding fields. */
|
||||
.fieldLabelRow {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75em;
|
||||
margin-bottom: 0.35em;
|
||||
}
|
||||
|
||||
.fieldLabelRow > label {
|
||||
margin-bottom: 0;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.fieldStatus {
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent, #2563eb);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.fieldStatus:not(:empty) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.fieldStatus--error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Chip strip: compact list of removable pills used by the "Manage
|
||||
favorites" affordance below the favorite-groups picker. Chips are
|
||||
click-target friendly on touch (min 32px tall) and truncate long
|
||||
labels with ellipsis so a wall of group names doesn't blow out the
|
||||
card width. */
|
||||
.chipStrip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4em;
|
||||
margin-top: 0.55em;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
max-width: 100%;
|
||||
padding: 0.3em 0.35em 0.3em 0.7em;
|
||||
background: var(--surface-muted, #f1f5f9);
|
||||
border: 1px solid var(--border-strong, #cbd5e1);
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.3;
|
||||
color: var(--text, #0f172a);
|
||||
}
|
||||
|
||||
.chip__label {
|
||||
max-width: 22ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.35em;
|
||||
height: 1.35em;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #64748b);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.chip__close:hover:not(:disabled),
|
||||
.chip__close:focus-visible {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chip__close:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
|
|
|||
|
|
@ -53,9 +53,14 @@
|
|||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="fieldLabelRow">
|
||||
<label for="favGroups">Favorite groups</label>
|
||||
<span id="favGroupsStatus" class="fieldStatus" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<select id="favGroups" multiple name="favGroups" placeholder="Favorite groups"
|
||||
data-silent-initial-value-set="false"></select>
|
||||
<div id="favGroupsManager" class="chipStrip hidden" aria-label="Manage favorite groups"></div>
|
||||
<span class="hint">Selecting a group under <em>Additional groups</em> saves it to your favorites. Click the × on a chip to remove that favorite.</span>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
|
|
|
|||
|
|
@ -16,6 +16,12 @@ var message = new EasyMDE({
|
|||
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.
|
||||
|
|
@ -72,21 +78,142 @@ function showImagePreview(event) {
|
|||
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) {
|
||||
for (var i = 0; i < groups.length; i++) {
|
||||
favoriteGroups = Array.isArray(groups) ? groups.slice() : [];
|
||||
for (var i = 0; i < favoriteGroups.length; i++) {
|
||||
var option = document.createElement('option');
|
||||
option.text = groups[i].name;
|
||||
option.value = groups[i].id;
|
||||
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')
|
||||
|
|
@ -99,9 +226,25 @@ function loadNewGroups(appName) {
|
|||
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;
|
||||
|
|
|
|||
73
index.js
73
index.js
|
|
@ -467,6 +467,79 @@ app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => {
|
|||
} else { res.status(401) }
|
||||
})
|
||||
|
||||
// Add / remove a favorite group for the calling user. Favorites live inside
|
||||
// config.json under `webex.bot[app].authorized[personId].groups` so they
|
||||
// survive restarts and are picklable in the compose page's Favorite Groups
|
||||
// selector. Both endpoints validate that:
|
||||
// - the caller is authorized for :app
|
||||
// - :id looks like a Webex SCIM group id we know about (present in the
|
||||
// cached org-wide group list) — this stops a bad actor from stuffing
|
||||
// arbitrary strings into the config.
|
||||
// Because index.js is single-process and node is single-threaded, concurrent
|
||||
// requests here can't corrupt config.json — every read/mutate/write executes
|
||||
// atomically within one handler.
|
||||
app.post('/CollabCentral/:app/user/groups/add', async function (req, res) {
|
||||
if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.');
|
||||
var appName = req.params.app;
|
||||
var personId = req.cookies.id;
|
||||
var groupId = req.body && req.body.id;
|
||||
if (!groupId) return res.status(400).send('Missing group id.');
|
||||
|
||||
var group;
|
||||
try {
|
||||
var allGroups = await getGroupsFromCache();
|
||||
group = allGroups.find(function (g) { return g.id === groupId; });
|
||||
} catch (err) {
|
||||
logger('apiEndpoint(' + appName + ')',
|
||||
'groups/add cache lookup failed: ' + (err && err.message || err));
|
||||
return res.status(502).send('Group list unavailable.');
|
||||
}
|
||||
if (!group) return res.status(404).send('Unknown group id.');
|
||||
|
||||
var favorites = config.webex.bot[appName].authorized[personId].groups || [];
|
||||
if (favorites.find(function (g) { return g.id === groupId; })) {
|
||||
return res.status(200).send(favorites);
|
||||
}
|
||||
favorites.push({ name: group.displayName, id: group.id });
|
||||
config.webex.bot[appName].authorized[personId].groups = favorites;
|
||||
try {
|
||||
saveConfig(config, './config/config.json');
|
||||
} catch (err) {
|
||||
logger('apiEndpoint(' + appName + ')',
|
||||
'groups/add save failed: ' + (err && err.message || err));
|
||||
return res.status(500).send('Failed to save favorite.');
|
||||
}
|
||||
logger('apiEndpoint(' + appName + ')',
|
||||
'groups/add ' + personId.slice(-8) + ' + "' + group.displayName + '"');
|
||||
res.status(200).send(favorites);
|
||||
});
|
||||
|
||||
app.post('/CollabCentral/:app/user/groups/remove', function (req, res) {
|
||||
if (!isAuthorized(req.params.app, req.cookies.id)) return res.status(401).send('Unauthorized.');
|
||||
var appName = req.params.app;
|
||||
var personId = req.cookies.id;
|
||||
var groupId = req.body && req.body.id;
|
||||
if (!groupId) return res.status(400).send('Missing group id.');
|
||||
|
||||
var favorites = config.webex.bot[appName].authorized[personId].groups || [];
|
||||
var before = favorites.length;
|
||||
var removed = favorites.find(function (g) { return g.id === groupId; });
|
||||
favorites = favorites.filter(function (g) { return g.id !== groupId; });
|
||||
if (favorites.length === before) return res.status(200).send(favorites);
|
||||
|
||||
config.webex.bot[appName].authorized[personId].groups = favorites;
|
||||
try {
|
||||
saveConfig(config, './config/config.json');
|
||||
} catch (err) {
|
||||
logger('apiEndpoint(' + appName + ')',
|
||||
'groups/remove save failed: ' + (err && err.message || err));
|
||||
return res.status(500).send('Failed to remove favorite.');
|
||||
}
|
||||
logger('apiEndpoint(' + appName + ')',
|
||||
'groups/remove ' + personId.slice(-8) + ' - "' + (removed && removed.name || groupId) + '"');
|
||||
res.status(200).send(favorites);
|
||||
});
|
||||
|
||||
// Options for the session cookies set after a successful OAuth round-trip.
|
||||
//
|
||||
// httpOnly is deliberately false: js/app.js reads `id` (to decide whether to
|
||||
|
|
|
|||
Loading…
Reference in a new issue