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>
This commit is contained in:
Joseph B. McQueen 2026-07-02 10:34:19 -04:00
parent 90eb889523
commit 3816880801
3 changed files with 104 additions and 36 deletions

View file

@ -334,6 +334,16 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
color: #b91c1c;
}
/* Right-aligned row of buttons directly below a picker (e.g. the "Save
selection to favorites" affordance under the Additional groups
picker). Sits between the picker and its hint. */
.fieldActions {
display: flex;
justify-content: flex-end;
gap: 0.5em;
margin-top: 0.4em;
}
/* 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

View file

@ -60,13 +60,17 @@
<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>
<span class="hint">Pick the favorites you want for this message. Click the × on a chip to remove a favorite you no longer use.</span>
</div>
<div class="field">
<label for="newGroups">Additional groups</label>
<select id="newGroups" multiple name="newGroups" placeholder="Search all groups"
data-silent-initial-value-set="false"></select>
<div class="fieldActions">
<button type="button" id="saveFavoritesBtn" class="btn btn-secondary" onclick="saveSelectionToFavorites()">Save selection to favorites</button>
</div>
<span class="hint">Pick any additional org groups for this message. They stay one-off unless you save them.</span>
</div>
<div class="field">

View file

@ -159,45 +159,110 @@ function onRemoveFavorite(event) {
});
}
// 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;
// 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 pending = 0;
var successes = 0;
var favGroupsEl = document.getElementById('favGroups');
var btn = document.getElementById('saveFavoritesBtn');
if (!newGroupsEl) return;
newlySelectedIds.forEach(function (id) {
if (favoriteGroups.some(function (g) { return g.id === id; })) return;
pending++;
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: id })
body: JSON.stringify({ id: c.id })
})
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
})
.then(function (updatedFavorites) {
favoriteGroups = updatedFavorites || favoriteGroups;
successes++;
renderFavoritesManager();
if (Array.isArray(updatedFavorites)) latestFavorites = updatedFavorites;
successIds.push(c.id);
})
.catch(function (err) {
console.error('Failed to add favorite ' + id + ':', 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 && successes > 0) {
flashFavoritesStatus(successes === 1
? 'Added to favorites.'
: 'Added ' + successes + ' to favorites.');
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.');
});
});
}
@ -226,20 +291,9 @@ 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);
});
// 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).
});
}