1import { Controller } from "@hotwired/stimulus" 2 3// Dynamic Waiver Form Fields â admin field-builder Stimulus controller. 4// 5// Manages the list of fields on a single waiver document: 6// ⢠Open the "Add field" modal â POST /create â append row. 7// ⢠Inline edit a field â PATCH /:id â swap row. 8// ⢠Soft-delete a field â DELETE /:id â fade row. 9// ⢠Reorder fields via up/down buttons â POST /reorder â reindex. 10// ⢠Publish schema version â POST /publish_schema_version â bump nonce. 11// 12// Targets: 13// list â the ordered <ul> of field rows 14// row â each row in the list 15// modal â the add/edit modal overlay 16// form â inline add/edit form inside the modal 17// fieldType â the <select> driving which type-specific config to show 18// typeConfig â wrapper <div> for type-specific config panes 19// publishStatus â text element showing "Active: version N · published <time>" 20// errors â server error banner inside the modal 21// 22// Values: 23// createUrl, reorderUrl, publishUrl 24// csrfToken â read from meta on connect 25export default class extends Controller { 26 static targets = ["list", "row", "modal", "form", "fieldType", "typeConfig", "publishStatus", "errors", 27 "optionsList", "libraryModal", "libraryList", "listRegion", "pagebar", "dirtyNote"] 28 static values = { 29 createUrl: String, 30 reorderUrl: String, 31 publishUrl: String, 32 libraryUrl: String, 33 addFromLibraryUrl: String, 34 saveToLibraryUrl: String, 35 deleteFromLibraryUrl: String, 36 listHtmlUrl: String, 37 embedded: Boolean, 38 documentType: String, 39 documentId: String 40 } 41 42 // Append the viewed document's identity so create/reorder/publish/edit all 43 // target the SAME waiver document the page is showing â otherwise the server 44 // falls back to the club's DEFAULT document and the save "vanishes". 45 _withDoc(url) { 46 if (!this.hasDocumentIdValue || !this.documentIdValue) return url 47 var sep = url.indexOf("?") === -1 ? "?" : "&" 48 return url + sep + "waiver_document_type=" + encodeURIComponent(this.documentTypeValue || "CampWaiverDocument") + 49 "&waiver_document_id=" + encodeURIComponent(this.documentIdValue) 50 } 51 52 connect() { 53 this._csrfToken = this._readCsrfToken() 54 this._editingId = null 55 this._dirty = false 56 // Dirty-guard: warn before leaving with unpublished field changes. 57 this._beforeUnload = (e) => { 58 if (!this._dirty) return undefined 59 e.preventDefault() 60 e.returnValue = "" 61 return "" 62 } 63 window.addEventListener("beforeunload", this._beforeUnload) 64 this._refreshTypeConfigVisibility() 65 } 66 67 disconnect() { 68 if (this._beforeUnload) window.removeEventListener("beforeunload", this._beforeUnload) 69 } 70 71 // Mark that fields changed since the last publish (drafts not yet live). 72 _markDirty() { 73 this._dirty = true 74 if (this.hasDirtyNoteTarget) this.dirtyNoteTarget.style.display = "inline-flex" 75 } 76 77 _clearDirty() { 78 this._dirty = false 79 if (this.hasDirtyNoteTarget) this.dirtyNoteTarget.style.display = "none" 80 } 81 82 // "Save & exit" â publish the current fields into a live schema version, then 83 // navigate to the back URL. Publishing is what makes draft edits take effect 84 // at signing time, so this is the real "save" for the page. 85 async saveAndExit(event) { 86 event && event.preventDefault() 87 const backUrl = event && event.currentTarget && event.currentTarget.dataset.backUrl 88 // Only publish when there are unpublished changes. A no-op publish mints a fresh 89 // schema nonce â content_version changes â re-gates EVERY prior signer of the doc 90 // (generalist-review 2026-07-07 MED). Clicking "Save & exit" with no edits must not 91 // force the whole signed population to re-sign. 92 if (this._dirty) { 93 try { 94 await this._publish() // publishes + clears dirty; alerts + throws on failure 95 } catch (_e) { 96 return // publish failed (already alerted) â stay so the admin can fix 97 } 98 }
99 if (backUrl) window.location.assign(backUrl) 100 } 101 102 // "Cancel" link â confirm before leaving if there are unpublished changes. 103 confirmLeave(event) { 104 if (this._dirty && !window.confirm("You have unpublished field changes. Leave without publishing? Draft changes are kept but won't be live until you publish.")) { 105 event && event.preventDefault() 106 } 107 } 108 109 // ââ Modal open / close âââââââââââââââââââââââââââââââââââââââââââââ 110 openAdd(event) { 111 event && event.preventDefault() 112 this._editingId = null 113 if (this.hasFormTarget) this.formTarget.reset() 114 this._renderOptionRows([]) // start with 2 blank choices 115 this._clearErrors() 116 this._showModal() 117 this._refreshTypeConfigVisibility() 118 } 119 120 openEdit(event) { 121 event && event.preventDefault() 122 const row = event.currentTarget.closest("[data-waiver-field-builder-target='row']") 123 if (!row) return 124 this._editingId = row.dataset.fieldId 125 this._populateFormFromRow(row) 126 this._clearErrors() 127 this._showModal() 128 this._refreshTypeConfigVisibility() 129 } 130 131 // ââ Dropdown choices builder âââââââââââââââââââââââââââââââââââââââ 132 addOption(event) { 133 event && event.preventDefault() 134 this._appendOptionRow("") 135 } 136 removeOption(event) { 137 event && event.preventDefault() 138 const row = event.currentTarget.closest(".wf-opt-row") 139 if (row) row.remove() 140 } 141 142 close(event) { 143 event && event.preventDefault() 144 this._hideModal() 145 } 146 147 // ââ Type-select side effect ââââââââââââââââââââââââââââââââââââââââ 148 onTypeChange() { 149 this._refreshTypeConfigVisibility() 150 } 151 152 // ââ Submit (Add or Update) âââââââââââââââââââââââââââââââââââââââââ 153 async submit(event) { 154 event.preventDefault() 155 const payload = this._serializeForm() 156 const baseUrl = this._editingId 157 ? this.createUrlValue.replace(/\/?$/, "/") + this._editingId 158 : this.createUrlValue 159 const url = this._withDoc(baseUrl) 160 const method = this._editingId ? "PATCH" : "POST" 161 162 try { 163 const res = await fetch(url, { 164 method, 165 headers: this._jsonHeaders(), 166 credentials: "same-origin", 167 body: JSON.stringify({ field: payload }) 168 }) 169 if (!res.ok) { 170 const data = await res.json().catch(() => ({})) 171 this._showErrors(data.errors || ["Save failed"]) 172 return 173 } 174 this._hideModal() 175 await this._refreshList() 176 this._markDirty() 177 this._toast(this._editingId ? "Field updated." : "Field added.") 178 } catch (err) { 179 this._showErrors([err.message || "Network error"]) 180 } 181 } 182 183 async destroy(event) { 184 event && event.preventDefault() 185 if (!confirm("Remove this field from this waiver? It can be restored within 30 days. (Your shared-library copy, if any, is not affected.)")) return 186 const row = event.currentTarget.closest("[data-waiver-field-builder-target='row']") 187 if (!row) return 188 const id = row.dataset.fieldId 189 try { 190 const res = await fetch(this._withDoc(this.createUrlValue.replace(/\/?$/, "/") + id), { 191 method: "DELETE", 192 headers: this._jsonHeaders(), 193 credentials: "same-origin" 194 }) 195 if (!res.ok) { alert("Couldn't remove"); return } 196 await this._refreshList() 197 this._markDirty() 198 this._toast("Removed from this waiver.") 199 } catch (err) { 200 alert("Network error") 201 } 202 } 203 204 async deleteFromLibrary(event) { 205 event && event.preventDefault() 206 if (!confirm("Delete this field from your shared library? It stays on this waiver, but you won't be able to add it from the library elsewhere.")) return 207 const row = event.currentTarget.closest("[data-waiver-field-builder-target='row']") 208 if (!row) return 209 const sep = this.deleteFromLibraryUrlValue.indexOf("?") === -1 ? "?" : "&" 210 try { 211 const res = await fetch(this._withDoc(this.deleteFromLibraryUrlValue + sep + "id=" + encodeURIComponent(row.dataset.f
211ieldId)), { 212 method: "POST", 213 headers: this._jsonHeaders(), 214 credentials: "same-origin" 215 }) 216 if (!res.ok) { alert("Couldn't delete from library"); return } 217 await this._refreshList() 218 this._toast("Deleted from your library.") 219 } catch (err) { 220 alert("Network error") 221 } 222 } 223 224 // ââ Drag-and-drop reorder (no reload) ââââââââââââââââââââââââââââââ 225 dragStart(event) { 226 this._dragRow = event.currentTarget 227 event.dataTransfer.effectAllowed = "move" 228 try { event.dataTransfer.setData("text/plain", this._dragRow.dataset.fieldId) } catch (_) {} 229 this._dragRow.classList.add("wf-dragging") 230 } 231 232 dragOver(event) { 233 event.preventDefault() 234 event.dataTransfer.dropEffect = "move" 235 const target = event.currentTarget 236 if (!this._dragRow || target === this._dragRow || !this.hasListTarget) return 237 const rect = target.getBoundingClientRect() 238 const after = (event.clientY - rect.top) > rect.height / 2 239 if (after) target.parentNode.insertBefore(this._dragRow, target.nextSibling) 240 else target.parentNode.insertBefore(this._dragRow, target) 241 } 242 243 drop(event) { event.preventDefault() } 244 245 async dragEnd(event) { 246 if (this._dragRow) this._dragRow.classList.remove("wf-dragging") 247 this._dragRow = null 248 await this._persistOrder() 249 } 250 251 async _persistOrder() { 252 if (!this.hasListTarget) return 253 const rows = Array.from(this.listTarget.querySelectorAll("[data-waiver-field-builder-target='row']")) 254 const ordering = {} 255 rows.forEach((r, i) => { ordering[r.dataset.fieldId] = i }) 256 try { 257 const res = await fetch(this._withDoc(this.reorderUrlValue), { 258 method: "POST", 259 headers: this._jsonHeaders(), 260 credentials: "same-origin", 261 body: JSON.stringify({ ordering }) 262 }) 263 if (!res.ok) { await this._refreshList(); this._toast("Reorder failed â restored.", "error"); return } 264 this._markDirty() 265 this._toast("Order saved.") 266 } catch (_) { 267 await this._refreshList() 268 } 269 } 270 271 // Re-render the toolbar + list in place (no page reload). §3.8 live-refresh. 272 async _refreshList() { 273 if (!this.hasListRegionTarget || !this.hasListHtmlUrlValue) return 274 let url = this._withDoc(this.listHtmlUrlValue) 275 if (this.embeddedValue) url += (url.indexOf("?") === -1 ? "?" : "&") + "embedded=1" 276 try { 277 const res = await fetch(url, { 278 headers: { "Accept": "text/html", "X-Requested-With": "XMLHttpRequest" }, 279 credentials: "same-origin" 280 }) 281 if (!res.ok) return 282 this.listRegionTarget.innerHTML = await res.text() 283 } catch (_) {} 284 } 285 286 _toast(message, kind) { 287 if (window.showToast) window.showToast(message, kind || "success") 288 } 289 290 // ââ Shared field library âââââââââââââââââââââââââââââââââââââââââââ 291 async openLibrary(event) { 292 event && event.preventDefault() 293 if (!this.hasLibraryModalTarget) return 294 this.libraryListTarget.innerHTML = '<div class="wf-lib-loading">Loading your libraryâ¦</div>' 295 this.libraryModalTarget.style.display = "flex" 296 try { 297 const res = await fetch(this._withDoc(this.libraryUrlValue), { 298 headers: this._jsonHeaders(), credentials: "same-origin" 299 }) 300 const data = await res.json().catch(() => ({ entries: [] })) 301 this._renderLibrary(data.entries || []) 302 } catch (err) { 303 this.libraryListTarget.innerHTML = '<div class="wf-lib-empty">Couldn\'t load the library.</div>' 304 } 305 } 306 307 closeLibrary(event) { 308 event && event.preventDefault() 309 if (this.hasLibraryModalTarget) this.libraryModalTarget.style.display = "none" 310 } 311 312 async addFromLibrary(event) { 313 event && event.preventDefault() 314 const id = event.currentTarget.dataset.entryId 315 if (!id) return 316 event.currentTarget.disabled = true 317 try { 318 const res = await fetch(this._withDoc(this.addFromLibraryUrlValue), { 319 method: "POST", 320 headers: this._jsonHeaders(), 321 credentials: "same-origin", 322 body: JSON.stringify({ library_entry_id: id }) 323 }) 324 if (!res.ok) { 325 const data = await res.json().catch(() => ({})) 326 alert((data.errors || ["Couldn't add field"]).join("; ")) 327 event.currentTarget.disabled = false 328 return 329 } 330 event.currentTarget.disabled = false 331 await this._refreshList() // live-refresh the list behind the picker 332 this._toast("Added from library.") 333 } catch (err) { 334 alert("Network error") 335 event.currentTarget.disabled = false 336 } 337 } 338 339 async saveToLibrary(event) { 340 event && event.preventDefault()
341 const row = event.currentTarget.closest("[data-waiver-field-builder-target='row']") 342 if (!row) return 343 const id = row.dataset.fieldId 344 try { 345 // save_to_library is a collection route keyed on ?id=<field>. 346 const sep = this.saveToLibraryUrlValue.indexOf("?") === -1 ? "?" : "&" 347 const res = await fetch(this._withDoc(this.saveToLibraryUrlValue + sep + "id=" + encodeURIComponent(id)), { 348 method: "POST", 349 headers: this._jsonHeaders(), 350 credentials: "same-origin" 351 }) 352 const data = await res.json().catch(() => ({})) 353 if (!res.ok) { 354 alert((data.errors || ["Couldn't save to library"]).join("; ")) 355 return 356 } 357 const msg = data.already_existed ? "Already in your library." : "Saved to library â reuse it on any waiver." 358 if (window.showToast) { window.showToast(msg, "success") } else { alert(msg) } 359 } catch (err) { 360 alert("Network error") 361 } 362 } 363 364 _renderLibrary(entries) { 365 if (!this.hasLibraryListTarget) return 366 if (!entries.length) { 367 this.libraryListTarget.innerHTML = 368 '<div class="wf-lib-empty">Your library is empty. Build a field, then use <strong>Save to library</strong> to reuse it on other waivers.</div>' 369 return 370 } 371 const esc = (s) => (s == null ? "" : String(s).replace(/[&<>"]/g, c => ( 372 { "&": "&", "<": "<", ">": ">", '"': """ }[c]))) 373 this.libraryListTarget.innerHTML = entries.map(e => { 374 const type = esc((e.field_type || "").replace(/_/g, " ")) 375 const med = e.is_medical ? '<span class="nx-pill nx-pill-error">medical</span>' : "" 376 return '<div class="wf-lib-row">' + 377 '<div class="wf-lib-meta"><div class="wf-lib-label">' + esc(e.label) + '</div>' + 378 '<div class="wf-lib-tags"><span class="nx-pill nx-pill-neutral">' + type + '</span>' + med + '</div></div>' + 379 '<button type="button" class="nx-btn nx-btn-secondary" data-action="click->waiver-field-builder#addFromLibrary" data-entry-id="' + esc(e.id) + '">' + 380 '<i class="fas fa-plus"></i> Add</button></div>' 381 }).join("") 382 } 383 384 async publish(event) { 385 event && event.preventDefault() 386 if (!confirm("Publish the current set of fields? Anyone signing this waiver after publish will see these questions.")) return 387 await this._publish() 388 this._toast("Published â new signers will see these fields.") 389 } 390 391 // Core publish (no confirm/toast) â shared by publish() and saveAndExit(). 392 async _publish() { 393 const res = await fetch(this._withDoc(this.publishUrlValue), { 394 method: "POST", 395 headers: this._jsonHeaders(), 396 credentials: "same-origin" 397 }) 398 const data = await res.json().catch(() => ({})) 399 if (!res.ok) { 400 alert((data.errors || ["Publish failed"]).join("; ")) 401 throw new Error("publish_failed") 402 } 403 this._clearDirty() 404 await this._refreshList() // toolbar now shows "published vN" 405 } 406 407 // ââ Helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 408 _showModal() { 409 if (this.hasModalTarget) this.modalTarget.style.display = "flex" 410 } 411 _hideModal() { 412 if (this.hasModalTarget) this.modalTarget.style.display = "none" 413 } 414 _refreshTypeConfigVisibility() { 415 // NB: Stimulus exposes `hasTypeConfigTarget` (singular) for "â¥1 exists"; 416 // there is no `hasTypeConfigTargets`. Using the plural here silently 417 // returned early forever, so type-specific config never revealed. 418 if (!this.hasFieldTypeTarget || !this.hasTypeConfigTarget) return 419 const t = this.fieldTypeTarget.value 420 this.typeConfigTargets.forEach(el => { 421 el.style.display = (el.dataset.forType === t) ? "block" : "none" 422 }) 423 } 424 425 _serializeForm() { 426 const fd = new FormData(this.formTarget) 427 const payload = {} 428 // NOTE: field_key intentionally omitted â it's auto-generated server-side 429 // from the label (WaiverFormField#assign_field_key_from_label). Admins 430 // never see or set the internal key. 431 const direct = ["label", "help_text", "field_type", 432 "placeholder", "max_length", "validation_regex", 433 "min_date", "max_date", "acknowledgment_phrase", 434 "display_order"] 435 direct.forEach(k => { 436 const v = fd.get(k) 437 if (v !== null && v !== "") payload[k] = v 438 }) 439 payload.required = fd.get("required") === "1" 440 payload.is_medical = fd.get("is_medical") === "1" 441 442 // Options for :select â built from the choice rows. The admin only types 443 // the label; the stored value is auto-derived (slugified) from it. 444 if (payload.field_type === "select" && this.hasOptionsListTarget) { 445 const seen = {} 446 payload.options = Array.from(this.optionsListTarget.querySelectorAll(".wf-opt-input")) 447 .map(inp => inp.value.trim()) 448 .filter(label => label.length > 0) 449 .map(label => { 450 let value = this._slugify(label) 451 while (seen[value]) value += "_" // de-dup collisions 452 seen[value] = true 453 return { value: value, label: label } 454 }) 455 } 456 return payload 457 } 458 459 _populateFormFromRow(row) {
460 const f = JSON.parse(row.dataset.fieldJson || "{}") 461 this._setVal("label", f.label) 462 this._setVal("help_text", f.help_text) 463 this._setVal("field_type", f.field_type) 464 this._setVal("placeholder", f.placeholder) 465 this._setVal("max_length", f.max_length) 466 this._setVal("acknowledgment_phrase", f.acknowledgment_phrase) 467 this._setCheck("required", f.required !== false) 468 this._setCheck("is_medical", !!f.is_medical) 469 this._renderOptionRows(Array.isArray(f.options) ? f.options : []) 470 } 471 472 // ââ Option-row + key helpers âââââââââââââââââââââââââââââââââââââââ 473 _renderOptionRows(options) { 474 if (!this.hasOptionsListTarget) return 475 this.optionsListTarget.innerHTML = "" 476 const labels = (options && options.length) ? options.map(o => o.label) : ["", ""] // min 2 blanks 477 labels.forEach(label => this._appendOptionRow(label)) 478 } 479 _appendOptionRow(label) { 480 if (!this.hasOptionsListTarget) return 481 const row = document.createElement("div") 482 row.className = "wf-opt-row" 483 const input = document.createElement("input") 484 input.type = "text" 485 input.className = "nx-input wf-opt-input" 486 input.placeholder = "Choice shown to parents" 487 input.value = label || "" 488 const btn = document.createElement("button") 489 btn.type = "button" 490 btn.className = "wf-opt-remove" 491 btn.setAttribute("aria-label", "Remove choice") 492 btn.textContent = "Ã" 493 btn.setAttribute("data-action", "click->waiver-field-builder#removeOption") 494 row.appendChild(input) 495 row.appendChild(btn) 496 this.optionsListTarget.appendChild(row) 497 } 498 499 _slugify(str) { 500 return (str || "").toString().toLowerCase().trim() 501 .replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 60) || "field" 502 } 503 504 _setVal(name, val) { 505 const el = this.formTarget.querySelector(`[name='${name}']`) 506 if (el && val !== undefined && val !== null) el.value = val 507 } 508 _setCheck(name, checked) { 509 const el = this.formTarget.querySelector(`[name='${name}']`) 510 if (el) el.checked = checked 511 } 512 513 _jsonHeaders() { 514 return { 515 "Content-Type": "application/json", 516 "Accept": "application/json", 517 "X-CSRF-Token": this._csrfToken, 518 "X-Requested-With": "XMLHttpRequest" 519 } 520 } 521 _readCsrfToken() { 522 const meta = document.querySelector('meta[name="csrf-token"]') 523 return meta ? meta.content : "" 524 } 525 _showErrors(errs) { 526 if (!this.hasErrorsTarget) { alert(errs.join("; ")); return } 527 this.errorsTarget.textContent = errs.join(" · ") 528 this.errorsTarget.style.display = "block" 529 } 530 _clearErrors() { 531 if (!this.hasErrorsTarget) return 532 this.errorsTarget.textContent = "" 533 this.errorsTarget.style.display = "none" 534 } 535};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.