From 524b443dce4424d7097492f3037ac01582ee5012 Mon Sep 17 00:00:00 2001 From: "Joseph B. McQueen" Date: Wed, 1 Jul 2026 20:25:15 -0400 Subject: [PATCH] Make favorite groups editable from the compose page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- html/css/app.css | 95 +++++++++++++++++++++++++++ html/sendMessage.html | 7 +- html/sendMessage.js | 149 +++++++++++++++++++++++++++++++++++++++++- index.js | 73 +++++++++++++++++++++ 4 files changed, 320 insertions(+), 4 deletions(-) diff --git a/html/css/app.css b/html/css/app.css index 074be61..e06f5bd 100644 --- a/html/css/app.css +++ b/html/css/app.css @@ -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; diff --git a/html/sendMessage.html b/html/sendMessage.html index d416d1e..c88bc8f 100644 --- a/html/sendMessage.html +++ b/html/sendMessage.html @@ -53,9 +53,14 @@
- +
+ + +
+ + Selecting a group under Additional groups saves it to your favorites. Click the × on a chip to remove that favorite.
diff --git a/html/sendMessage.js b/html/sendMessage.js index 06a6f44..aa33f55 100644 --- a/html/sendMessage.js +++ b/html/sendMessage.js @@ -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 + //