{# strip images & classes from the HTML but otherwise leave structure #}
Click a connector's name and it turns into a dropdown of that opportunity's active connector group members. Pick someone and it saves right away, with a green "Saved" check to confirm. Changed rows stay highlighted so you can see what you touched. It also adds a Connector Group button next to the Edit button on each opportunity, so you can quickly add people to the list of connectors you can choose from.
Everything lives in a single HTML block with no hardcoded IDs, so it should work out of the box and be quick to set up.
{%- assign debug = 'Global' | PageParameter:'Debug' | Downcase -%}
{%- assign rawCocId = 'Global' | PageParameter:'SaveCocId' | Trim -%}
{%- assign rawAliasId = 'Global' | PageParameter:'SaveAliasId' | Trim | Default:'0' -%}
{%- if rawCocId != empty -%}
{%- comment -%} ===== SAVE MODE (called by the dropdown script) ===== {%- endcomment -%}
{%- assign cocIsNumber = rawCocId | RegExMatch:'^[0-9]{1,10}$' -%}
{%- assign aliasIsNumber = rawAliasId | RegExMatch:'^[0-9]{1,10}$' -%}
{%- if cocIsNumber != true or aliasIsNumber != true -%}
<div id="dc-save-result" data-status="error" data-message="Invalid parameters"></div>
{%- else -%}
{%- assign saveCocId = rawCocId | AsInteger -%}
{%- assign saveAliasId = rawAliasId | AsInteger -%}
{%- sql return:'cocRow' cocid:'{{ saveCocId }}' -%}
SELECT TOP 1 ConnectionOpportunityId
FROM ConnectionOpportunityCampus
WHERE Id = TRY_CAST(@cocid AS INT)
{%- endsql -%}
{%- assign saveOppId = cocRow | First | Property:'ConnectionOpportunityId' | Default:0 -%}
{%- if saveOppId == 0 -%}
<div id="dc-save-result" data-status="error" data-message="Connection opportunity campus not found"></div>
{%- else -%}
{%- assign saveCanEdit = saveOppId | HasRightsTo:'Edit','Rock.Model.ConnectionOpportunity' -%}
{%- if saveCanEdit != true -%}
<div id="dc-save-result" data-status="error" data-message="You do not have edit rights on this opportunity"></div>
{%- else -%}
{%- sql return:'saveResult' cocid:'{{ saveCocId }}' aliasid:'{{ saveAliasId }}' modifiedby:'{{ CurrentPerson.PrimaryAliasId | Default:0 }}' -%}
SET NOCOUNT ON;
DECLARE @vCoc INT = TRY_CAST(@cocid AS INT);
DECLARE @vAlias INT = TRY_CAST(@aliasid AS INT);
DECLARE @vModBy INT = NULLIF(TRY_CAST(@modifiedby AS INT), 0);
DECLARE @vRows INT = 0;
BEGIN TRY
IF @vCoc IS NULL OR @vAlias IS NULL
SELECT 'error' AS Status, 'Invalid parameters' AS Message, 0 AS RowsUpdated;
ELSE
BEGIN
UPDATE ConnectionOpportunityCampus
SET DefaultConnectorPersonAliasId = NULLIF(@vAlias, 0),
ModifiedDateTime = GETDATE(),
ModifiedByPersonAliasId = @vModBy
WHERE Id = @vCoc;
SET @vRows = @@ROWCOUNT;
IF @vRows = 1
SELECT 'ok' AS Status, 'Saved' AS Message, @vRows AS RowsUpdated;
ELSE
SELECT 'error' AS Status, 'No rows were updated' AS Message, @vRows AS RowsUpdated;
END
END TRY
BEGIN CATCH
SELECT 'error' AS Status, 'Database error: ' + ERROR_MESSAGE() AS Message, 0 AS RowsUpdated;
END CATCH
{%- endsql -%}
{%- assign result = saveResult | First -%}
<div id="dc-save-result"
data-status="{{ result.Status | Default:'error' | Escape }}"
data-rows="{{ result.RowsUpdated | Default:0 }}"
data-message="{{ result.Message | Default:'No response from database' | Escape }}"></div>
{%- endif -%}
{%- endif -%}
{%- endif -%}
{%- else -%}
{%- comment -%} ===== NORMAL PAGE MODE ===== {%- endcomment -%}
<div class="dc-root">
{%- if debug == 'true' -%}
<div class="alert alert-info">
<strong>DEBUG</strong><br>
Current Person: {{ CurrentPerson.FullName }} (Alias {{ CurrentPerson.PrimaryAliasId }})<br>
Save method: same-page GET with SaveCocId / SaveAliasId. Open the console (F12) when changing a connector.
</div>
{%- endif -%}
{%- connectionopportunity
expression:'IsActive == true && ConnectionType.IsActive == true'
select:'new (
Id,
Name,
IconCssClass,
new (
ConnectionType.Id AS Id,
ConnectionType.Name AS Name,
ConnectionType.IconCssClass AS IconCssClass
) AS ConnectionType,
ConnectionOpportunityCampuses.Select(new (
Id AS ConnectionOpportunityCampusId,
Campus.Name AS Name,
Campus.CampusStatusValue.Value AS Status,
DefaultConnectorPersonAliasId,
DefaultConnectorPersonAlias.Person.NickName + " " + DefaultConnectorPersonAlias.Person.LastName AS Connector,
Campus.Order AS Order
)) AS Campuses
)'
sort:'ConnectionTypeId, Name'
securityenabled:'false' -%}
{%- assign connectionTypes = connectionopportunityItems | Select:'ConnectionType' | Uniq -%}
{%- for connectionType in connectionTypes -%}
{[ panel title:'{{ connectionType.Name }}' icon:'{{ connectionType.IconCssClass }}' type:'block' ]}
{%- assign typeOpportunities = connectionopportunityItems
| Where:'ConnectionType.Id',connectionType.Id
| OrderBy:'Name' -%}
{%- for opportunity in typeOpportunities -%}
{%- assign rowPos = forloop.index0 | Modulo:3 | Plus:1 -%}
{%- if rowPos == 1 -%}<div class="row">{%- endif -%}
<div class="col-md-6 col-lg-4">
{%- assign canEdit = opportunity.Id | HasRightsTo:'Edit','Rock.Model.ConnectionOpportunity' -%}
{%- sql return:'connectorGroup' oppid:'{{ opportunity.Id }}' %}
SELECT TOP 1 ocg.ConnectorGroupId
FROM ConnectionOpportunityConnectorGroup ocg
WHERE ocg.ConnectionOpportunityId = TRY_CAST(@oppid AS INT)
{% endsql %}
{%- assign connectorGroupId = connectorGroup | First | Property:'ConnectorGroupId' -%}
{%- if canEdit -%}
{% sql return:'people' oppid:'{{ opportunity.Id }}' %}
SELECT
p.PrimaryAliasId AS PersonAliasId,
MAX(p.NickName + ' ' + p.LastName) AS Name
FROM ConnectionOpportunityConnectorGroup ocg
JOIN GroupMember gm ON gm.GroupId = ocg.ConnectorGroupId
JOIN Person p ON p.Id = gm.PersonId
WHERE ocg.ConnectionOpportunityId = TRY_CAST(@oppid AS INT)
AND gm.GroupMemberStatus = 1
AND p.PrimaryAliasId IS NOT NULL
GROUP BY p.PrimaryAliasId
ORDER BY MAX(p.LastName), MAX(p.NickName)
{% endsql %}
{%- endif -%}
<div class="panel panel-default">
<div class="panel-heading clearfix">
{%- if canEdit -%}
<div class="pull-right">
{%- if connectorGroupId -%}
<a href="/Group/{{ connectorGroupId }}" class="btn btn-xs btn-square btn-default" title="View Connector Group">
<i class="fa fa-fw fa-users"></i>
</a>
{%- endif -%}
<a href="/people/connections/types/{{ connectionType.Id }}/opportunity/{{ opportunity.Id }}" class="btn btn-xs btn-square btn-default" title="Edit this Connection Opportunity">
<i class="fa fa-fw fa-pencil"></i>
</a>
</div>
{%- endif -%}
<h3 class="panel-title">
<i class="{{ opportunity.IconCssClass }}"></i> {{ opportunity.Name | Escape }}
{%- if debug == 'true' %} <small>(Opp {{ opportunity.Id }}, canEdit {{ canEdit }}, group {{ connectorGroupId | Default:'none' }})</small>{% endif -%}
</h3>
</div>
<div class="panel-body">
<div class="grid grid-panel">
<table class="table grid-table table-condensed">
<thead>
<tr>
<th>Campus</th>
<th>Default Connector</th>
</tr>
</thead>
<tbody>
{%- assign sortedCampuses = opportunity.Campuses | OrderBy:'Order' -%}
{%- for campus in sortedCampuses -%}
{%- if campus.Status == 'Open' -%}
<tr>
<td>{{ campus.Name | Escape }}{% if debug == 'true' %} <small class="text-muted">(COC {{ campus.ConnectionOpportunityCampusId }})</small>{% endif %}</td>
<td>
{%- if canEdit -%}
{%- assign defaultAliasId = campus.DefaultConnectorPersonAliasId | Default:0 -%}
<span class="connector-inline">
<span class="dc-status" aria-live="polite"></span>
<button type="button" class="form-control input-sm dc-display" title="Click to change">
<span class="dc-display-text">{% if defaultAliasId != 0 %}{{ campus.Connector | Escape }}{% else %}<span class="dc-placeholder">Select connector</span>{% endif %}</span>
<i class="fa fa-caret-down dc-caret"></i>
</button>
<select
class="form-control input-sm connector-select dc-hidden"
data-coc-id="{{ campus.ConnectionOpportunityCampusId }}"
data-original-alias-id="{{ defaultAliasId }}"
data-current-alias-id="{{ defaultAliasId }}">
{%- if defaultAliasId == 0 -%}
<option value="" selected disabled hidden></option>
{%- endif -%}
{%- for person in people -%}
<option value="{{ person.PersonAliasId }}"{% if person.PersonAliasId == defaultAliasId %} selected{% endif %}>{{ person.Name | Escape }}</option>
{%- endfor -%}
</select>
</span>
{%- else -%}
{{ campus.Connector | Escape }}
{%- endif -%}
</td>
</tr>
{%- endif -%}
{%- endfor -%}
</tbody>
</table>
</div>
</div>
</div>
</div>
{%- if rowPos == 3 or forloop.last -%}</div>{%- endif -%}
{%- endfor -%}
{[ endpanel ]}
{%- endfor -%}
{%- endconnectionopportunity -%}
</div>
<script>
(function () {
var DEBUG = {% if debug == 'true' %}true{% else %}false{% endif %};
var TIMEOUT_MS = 15000;
function log() {
if (DEBUG) console.log.apply(console, ["[DefaultConnectors]"].concat([].slice.call(arguments)));
}
function getAttr(select, name) {
var v = select.getAttribute(name);
if (v === null || v === undefined || v === "") return "0";
return String(v);
}
function parts(el) {
var wrap = el.closest(".connector-inline");
return {
wrap: wrap,
select: wrap.querySelector(".connector-select"),
display: wrap.querySelector(".dc-display"),
text: wrap.querySelector(".dc-display-text"),
status: wrap.querySelector(".dc-status")
};
}
function updateChanged(p) {
var original = getAttr(p.select, "data-original-alias-id");
var changed = getAttr(p.select, "data-current-alias-id") !== original;
p.display.classList.toggle("is-changed", changed);
}
function setDisplayText(p) {
var opt = p.select.options[p.select.selectedIndex];
if (opt && opt.value) {
p.text.textContent = opt.textContent;
} else {
p.text.innerHTML = '<span class="dc-placeholder">Select connector</span>';
}
}
function showDisplay(p) {
p.select.classList.add("dc-hidden");
p.display.classList.remove("dc-hidden");
}
function showSelect(p) {
p.display.classList.add("dc-hidden");
p.select.classList.remove("dc-hidden");
p.select.focus();
try { if (p.select.showPicker) p.select.showPicker(); } catch (err) { }
}
function hideStatus(s) {
s.className = "dc-status";
s._clear = setTimeout(function () { s.innerHTML = ""; s.title = ""; }, 250);
}
function showStatus(p, type, text, detail) {
var s = p.status;
clearTimeout(s._timer);
clearTimeout(s._clear);
s.className = "dc-status visible " + type;
s.title = detail || "";
if (type === "ok") {
s.innerHTML = 'Saved <i class="fa fa-check"></i>';
s._timer = setTimeout(function () { hideStatus(s); }, 1500);
} else if (type === "saving") {
s.innerHTML = '<i class="fa fa-spinner fa-spin"></i>';
} else {
s.innerHTML = '<i class="fa fa-exclamation-triangle"></i> ' + text;
s._timer = setTimeout(function () { hideStatus(s); }, 6000);
}
}
function revert(p, saved) {
p.select.value = (saved === "0") ? "" : saved;
setDisplayText(p);
updateChanged(p);
}
log("Editable connectors on page:", document.querySelectorAll(".dc-root .connector-select").length);
document.addEventListener("click", function (e) {
var btn = e.target.closest ? e.target.closest(".dc-root .dc-display") : null;
if (!btn) return;
showSelect(parts(btn));
});
document.addEventListener("focusout", function (e) {
var el = e.target;
if (!el.classList || !el.classList.contains("connector-select") || el.disabled) return;
showDisplay(parts(el));
});
document.addEventListener("keydown", function (e) {
var el = e.target;
if (e.key === "Escape" && el.classList && el.classList.contains("connector-select")) {
var p = parts(el);
revert(p, getAttr(el, "data-current-alias-id"));
showDisplay(p);
}
});
document.addEventListener("change", function (e) {
var el = e.target;
if (!el.classList || !el.classList.contains("connector-select")) return;
var p = parts(el);
var cocId = el.getAttribute("data-coc-id");
var saved = getAttr(el, "data-current-alias-id");
var val = String(el.value || "0");
setDisplayText(p);
if (val === saved) { log("No change, skipping"); showDisplay(p); return; }
if (!/^[0-9]+$/.test(cocId) || !/^[0-9]+$/.test(val)) {
revert(p, saved);
showDisplay(p);
showStatus(p, "error", "Invalid value", "Non-numeric id: coc=" + cocId + " alias=" + val);
return;
}
el.disabled = true;
showDisplay(p);
p.display.disabled = true;
showStatus(p, "saving");
var u = new URL(window.location.href);
u.searchParams.delete("Debug");
u.searchParams.set("SaveCocId", cocId);
u.searchParams.set("SaveAliasId", val);
u.searchParams.set("_", Date.now());
var url = u.toString();
var controller = ("AbortController" in window) ? new AbortController() : null;
var timer = controller ? setTimeout(function () { controller.abort(); }, TIMEOUT_MS) : null;
log("Saving", { cocId: cocId, from: saved, to: val, url: url });
fetch(url, { credentials: "same-origin", cache: "no-store", signal: controller ? controller.signal : undefined })
.then(function (r) {
log("Response status:", r.status, "Final URL:", r.url);
if (r.redirected && /login/i.test(r.url)) throw new Error("Your session expired. Refresh and log in again.");
if (!r.ok) throw new Error("Server returned HTTP " + r.status);
return r.text();
})
.then(function (body) {
var doc = new DOMParser().parseFromString(body, "text/html");
var result = doc.getElementById("dc-save-result");
if (!result) {
log("Response body (first 1000 chars):", body.substring(0, 1000));
throw new Error("No save result returned. Open the save URL directly to see the error.");
}
var status = result.getAttribute("data-status");
var message = result.getAttribute("data-message") || "Unknown error";
log("Save result:", status, message, "rows:", result.getAttribute("data-rows"));
if (status !== "ok") throw new Error(message);
el.setAttribute("data-current-alias-id", val);
updateChanged(p);
showStatus(p, "ok");
})
.catch(function (err) {
var msg = (err && err.name === "AbortError") ? "Timed out. Try again." : (err && err.message) || "Unknown error";
console.error("[DefaultConnectors] Save failed:", msg, url);
revert(p, saved);
showStatus(p, "error", "Not saved", msg);
})
.finally(function () {
if (timer) clearTimeout(timer);
el.disabled = false;
p.display.disabled = false;
});
});
})();
</script>
<style>
.dc-root {
--dc-changed-bg: rgba(74, 144, 226, 0.15);
--dc-changed-border: #4a90e2;
--dc-ok: #1e8e3e;
--dc-error: #c62828;
--dc-muted: rgba(127, 127, 127, 0.9);
}
@media (prefers-color-scheme: dark) {
.dc-root {
--dc-changed-bg: rgba(100, 160, 255, 0.22);
--dc-changed-border: #6aa8ff;
--dc-ok: #5fd08a;
--dc-error: #ff7b7b;
}
}
html.dark .dc-root,
html.theme-dark .dc-root,
body.dark .dc-root,
[data-theme="dark"] .dc-root,
[data-bs-theme="dark"] .dc-root {
--dc-changed-bg: rgba(100, 160, 255, 0.22);
--dc-changed-border: #6aa8ff;
--dc-ok: #5fd08a;
--dc-error: #ff7b7b;
}
html.light .dc-root,
[data-theme="light"] .dc-root,
[data-bs-theme="light"] .dc-root {
--dc-changed-bg: rgba(74, 144, 226, 0.15);
--dc-changed-border: #4a90e2;
--dc-ok: #1e8e3e;
--dc-error: #c62828;
}
.dc-root .panel-heading { padding-left: 12px; }
.dc-root .panel-block > .panel-heading .panel-title i { display: inline-block; }
.dc-root .dc-hidden { display: none !important; }
.dc-root .dc-display {
display: flex;
align-items: center;
justify-content: space-between;
text-align: left;
cursor: pointer;
user-select: text;
}
.dc-root .dc-display-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dc-root .dc-placeholder { color: var(--dc-muted); font-style: italic; }
.dc-root .dc-caret { margin-left: 6px; opacity: 0.6; }
.dc-root .dc-display.is-changed {
background-color: var(--dc-changed-bg);
border-color: var(--dc-changed-border);
}
.dc-root .dc-display:disabled,
.dc-root .connector-select:disabled { opacity: 0.7; cursor: wait; }
.dc-root span.connector-inline {
position: relative;
display: block;
width: 100%;
}
.dc-root .dc-status {
position: absolute;
right: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
font-size: 12px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.dc-root .dc-status.visible { opacity: 1; }
.dc-root .dc-status.ok { color: var(--dc-ok); }
.dc-root .dc-status.error { color: var(--dc-error); cursor: help; pointer-events: auto; }
.dc-root .dc-status.saving { color: var(--dc-muted); }
</style>
{%- endif -%}
Massive thanks to Jeff for the original recipe. Leave a comment below if you have questions or run into any issues.