2 Default Connectors Inline Editor Shared by Hadley Sutherland, Centre Street Church 6 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