2 Default Connectors Inline Editor Shared by Hadley Sutherland, Centre Street Church 26 days ago 13.0 Connection Beginner This is an extension of Jeff Richmond's Default Connectors Quick Reference List recipe. He had a great idea, and I added editing capabilities so you can change connectors right from the list. 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. How-To Create a new page under your main Connections page (Internal Homepage > People > Engagement > Connection): Page Title: Default Connectors Layout: Full Width Icon CSS Class: fa fa-users Page Route: people/connections/connectors Add an HTML Content block to the Main zone: Block Title: Default Connectors Enabled Lava Commands: Rock Entity, Sql Cache Duration: 0 (required, or saves won't work) Paste the following into the HTML Content: Copy {%- 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 -%} Troubleshooting Add ?Debug=true to the page URL to show IDs and permissions on the page and log every save to the browser console (F12). If a save fails, hover the red "Not saved" message for the reason. Follow Up Massive thanks to Jeff for the original recipe. Leave a comment below if you have questions or run into any issues. If your church could use help with Rock workflows, dashboards, or tools like this one, I do some contract work on the side. You can find me at hadleysutherland.netlify.app. Thank you so much! Change Log 2026-09-21: Initial version