7d56cd6635651dfd4c9926d062d92ad7b65a3e80 chmalee Wed Jun 3 14:52:52 2026 -0700 More myVariants changes: make the share to accept a comma-sep list of usernames, allow both the read/edit flag and the usernames setting to be editable after creation. When editing usernames, add a confirmation if the field is left blank that this will make the share viewable/editable by anyone with the link, refs #33808 diff --git src/hg/js/hui.js src/hg/js/hui.js index 8bee32dd4c8..86481c5e954 100644 --- src/hg/js/hui.js +++ src/hg/js/hui.js @@ -1687,67 +1687,128 @@ table.style.width = "100%"; table.style.borderCollapse = "collapse"; table.style.fontSize = "0.9em"; let header = document.createElement("tr"); header.style.borderBottom = "1px solid #ccc"; ["Project", "Permission", "Shared With", "Label", "Created", ""].forEach(function(label) { let th = document.createElement("th"); th.style.textAlign = "left"; th.textContent = label; header.appendChild(th); }); table.appendChild(header); data.shares.forEach(function(s) { let tr = document.createElement("tr"); tr.style.borderBottom = "1px solid #eee"; - let cells = [ - s.project === "*" ? "All" : s.project, - s.permission === 0 ? "View" : "Edit", - s.targetUser ? s.targetUser : "Anyone with link", - s.label ? s.label : "", - s.createdAt - ]; - cells.forEach(function(text) { + + // Project (plain text) + let projTd = document.createElement("td"); + projTd.textContent = s.project === "*" ? "All" : s.project; + tr.appendChild(projTd); + + // Permission: a View/Edit dropdown that applies immediately on change + let permTd = document.createElement("td"); + let permSel = document.createElement("select"); + permSel.dataset.token = s.shareToken; + [["0", "View"], ["1", "Edit"]].forEach(function(opt) { + let o = document.createElement("option"); + o.value = opt[0]; + o.textContent = opt[1]; + permSel.appendChild(o); + }); + permSel.value = String(s.permission); + permSel.addEventListener("change", function() { + myVariantsShareSetPermission(this.dataset.token, this.value); + }); + permTd.appendChild(permSel); + tr.appendChild(permTd); + + // Shared With: editable comma-separated username list + Save link + let usersTd = document.createElement("td"); + let usersInput = document.createElement("input"); + usersInput.type = "text"; + usersInput.style.width = "180px"; + usersInput.value = s.targetUser ? s.targetUser : ""; + usersInput.placeholder = "blank = anyone with link"; + usersInput.dataset.token = s.shareToken; + let saveLink = document.createElement("a"); + saveLink.href = "#"; + saveLink.textContent = "Save"; + saveLink.style.marginLeft = "4px"; + saveLink.addEventListener("click", function(e) { + e.preventDefault(); + myVariantsShareSetTargets(usersInput.dataset.token, usersInput.value); + }); + usersTd.appendChild(usersInput); + usersTd.appendChild(saveLink); + tr.appendChild(usersTd); + + // Label, Created (plain text) + [s.label ? s.label : "", s.createdAt].forEach(function(text) { let td = document.createElement("td"); td.textContent = text; tr.appendChild(td); }); + let actionTd = document.createElement("td"); let revokeLink = document.createElement("a"); revokeLink.href = "#"; revokeLink.className = "shareRevokeLink"; revokeLink.dataset.token = s.shareToken; revokeLink.textContent = "Revoke"; revokeLink.addEventListener("click", function(e) { e.preventDefault(); myVariantsShareRevoke(this.dataset.token); }); actionTd.appendChild(revokeLink); tr.appendChild(actionTd); table.appendChild(tr); }); content.appendChild(table); }) .catch(function(err) { content.textContent = "Error loading shares: " + err.message; }); } +function myVariantsShareNormalizeTargets(raw) { + // Split a comma-separated username list, trim, drop empties, de-dup. + // Returns {value: "a,b", error: null} or {value: null, error: "..."}. + // Server remains the authoritative validator (membership, length). + let seen = {}; + let names = []; + let bad = null; + raw.split(",").forEach(function(tok) { + let name = tok.trim(); + if (name === "") return; + if (/\s/.test(name)) bad = name; + if (!seen[name]) { seen[name] = true; names.push(name); } + }); + if (bad !== null) + return { value: null, error: "username '" + bad + "' contains a space" }; + return { value: names.join(","), error: null }; +} + function myVariantsShareCreate() { let project = document.getElementById("shareProject").value; let permission = document.querySelector('input[name="sharePerm"]:checked').value; - let targetUser = document.getElementById("shareTargetUser").value.trim(); + let norm = myVariantsShareNormalizeTargets(document.getElementById("shareTargetUser").value); + if (norm.error) { + warn("Error creating share: " + norm.error); + return; + } + let targetUser = norm.value; let label = document.getElementById("shareLabel").value.trim(); let params = { project: project, permission: permission }; if (targetUser) params.targetUser = targetUser; if (label) params.label = label; fetch(myVariantsShareApiUrl("createShare", params), { credentials: "same-origin" }) .then(function(response) { return response.json(); }) .then(function(data) { if (data.error) { warn("Error creating share: " + data.error); return; } let shareUrl = window.location.protocol + "//" + window.location.host + "/cgi-bin/hgTracks?myVarShare=" + data.token + "&db=" + getDb(); @@ -1764,30 +1825,75 @@ fetch(myVariantsShareApiUrl("revokeShare", { shareToken: token }), { credentials: "same-origin" }) .then(function(response) { return response.json(); }) .then(function(data) { if (data.error) { warn("Error revoking share: " + data.error); return; } myVariantsShareLoad(); }) .catch(function(err) { warn("Error revoking share: " + err.message); }); } +function myVariantsShareSetPermission(token, permission) { + fetch(myVariantsShareApiUrl("setSharePermission", { shareToken: token, permission: permission }), + { credentials: "same-origin" }) + .then(function(response) { return response.json(); }) + .then(function(data) { + if (data.error) + warn("Error updating permission: " + data.error); + // Reload either way so the controls reflect the true server state. + myVariantsShareLoad(); + }) + .catch(function(err) { + warn("Error updating permission: " + err.message); + myVariantsShareLoad(); + }); +} + +function myVariantsShareSetTargets(token, rawTargetUser) { + let norm = myVariantsShareNormalizeTargets(rawTargetUser); + if (norm.error) { + warn("Error updating recipients: " + norm.error); + return; + } + if (!norm.value && + !confirm("Leaving the recipient list blank lets anyone with the link read this " + + "track (and edit it if the link permission is set to Edit). Continue?")) { + myVariantsShareLoad(); + return; + } + // Pass the normalized list explicitly; "" means "anyone with link", which + // myVariantsShareApiUrl would otherwise drop, so build params directly. + let params = { shareToken: token }; + if (norm.value) params.targetUser = norm.value; + fetch(myVariantsShareApiUrl("setShareTargets", params), { credentials: "same-origin" }) + .then(function(response) { return response.json(); }) + .then(function(data) { + if (data.error) + warn("Error updating recipients: " + data.error); + myVariantsShareLoad(); + }) + .catch(function(err) { + warn("Error updating recipients: " + err.message); + myVariantsShareLoad(); + }); +} + function myVariantsShareInit() { // Wire up the create/copy buttons and load existing shares let createBtn = document.getElementById("shareCreateBtn"); if (!createBtn) return; createBtn.addEventListener("click", myVariantsShareCreate); let copyBtn = document.getElementById("shareCopyBtn"); if (copyBtn) { copyBtn.addEventListener("click", function() { let urlField = document.getElementById("shareUrlField"); urlField.select(); navigator.clipboard.writeText(urlField.value); }); } myVariantsShareLoad(); }