PageSourceSearch

https://www.picklebeastpickleball.com/assets/controllers/waiver_fi…b285ab2d99d057c6d8cf08b0a87d1c7ad217c86a.js

js picklebeastpickleball.com collected 2026-09-24 14:18:46 UTC 21,164 bytes, 535 lines download raw bytes

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      { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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.