PageSourceSearch

https://www.lombardchamber.com/assets/controllers/page_dirty_state_controller-85c6fa51.js

js lombardchamber.com collected 2026-09-24 12:46:53 UTC 8,471 bytes, 317 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2import { Turbo } from "@hotwired/turbo-rails"
3
4export default class extends Controller {
5  static targets = [
6    "status",
7    "dialog",
8    "saveBar",
9    "title",
10    "message",
11    "discardButton",
12    "discardTrigger",
13    "form",
14  ]
15  static values = { initiallyDirty: Boolean }
16
17  connect() {
18    this.dirty = this.hasInitiallyDirtyValue ? this.initiallyDirtyValue : false
19    this.submitting = false
20    this.pendingVisitUrl = null
21    this.confirmedLeave = false
22    this.dialogPurpose = null
23    this.dialogReturnFocus = null
24
25    this.boundBeforeVisit = this.beforeVisit.bind(this)
26    this.boundBeforeUnload = this.beforeUnload.bind(this)
27
28    document.addEventListener("turbo:before-visit", this.boundBeforeVisit)
29    window.addEventListener("beforeunload", this.boundBeforeUnload)
30    this.renderState()
31  }
32
33  disconnect() {
34    document.removeEventListener("turbo:before-visit", this.boundBeforeVisit)
35    window.removeEventListener("beforeunload", this.boundBeforeUnload)
36  }
37
38  markDirty(event) {
39    if (!this.shouldTrackDirtyEvent(event)) return
40    if (this.dirty || this.submitting) return
41    this.dirty = true
42    this.renderState()
43  }
44
45  submitStarted() {
46    this.submitting = true
47    this.renderState()
48  }
49
50  submitEnded(event) {
51    this.submitting = false
52
53    if (event?.detail?.success) {
54      this.dirty = false
55    }
56
57    this.renderState()
58  }
59
60  stay(event) {
61    event?.preventDefault()
62    this.pendingVisitUrl = null
63    this.dialogPurpose = null
64    this.closeDialog()
65  }
66
67  discardAndLeave() {
68    const url = this.pendingVisitUrl
69    const purpose = this.dialogPurpose
70    this.pendingVisitUrl = null
71    this.dialogPurpose = null
72    this.closeDialog({ restoreFocus: false })
73    this.confirmedLeave = true
74
75    if (purpose === "reload") {
76      window.location.reload()
77      return
78    }
79
80    if (!url) return
81    Turbo.visit(url)
82  }
83
84  requestDiscard(event) {
85    event?.preventDefault()
86    if (!this.shouldWarn()) return
87
88    this.pendingVisitUrl = null
89    this.dialogPurpose = "reload"
90    this.openDialog({
91      title: "Discard unsaved changes?",
92      message: "Return this page to its last saved version? Any unsaved edits will be lost.",
93      allowDiscard: true,
94      returnFocus: event?.currentTarget,
95    })
96  }
97
98  // Keep the old action working for forms that have not adopted the clearer
99  // Discard changes label yet. It now uses the same confirmation flow.
100  resetPage(event) {
101    this.requestDiscard(event)
102  }
103
104  requireSaved(event) {
105    if (this.submitting) {
106      event.preventDefault()
107      event.stopImmediatePropagation()
108      return
109    }
110
111    if (!this.shouldWarn()) return
112
113    event.preventDefault()
114    event.stopImmediatePropagation()
115    this.pendingVisitUrl = null
116    this.dialogPurpose = "save-required"
117
118    // No form to save against — fall back to the original blocking guard.
119    if (!this.hasFormTarget) {
120      this.openDialog({
121        title: "Save changes first",
122        message: "Save your draft before using this action so it uses the latest saved content and audience.",
123        allowDiscard: false,
124        returnFocus: event.currentTarget,
125      })
126      return
127    }
128
129    // Save the draft quietly, then re-run the action the user clicked (e.g. open Queue).
130    this.saveThenRetrigger(event.currentTarget)
131  }
132
133  async saveThenRetrigger(triggerEl) {
134    const saved = await this.inlineSave()
135    if (saved && triggerEl) triggerEl.click()
136  }
137
138  async inlineSave() {
139    if (this.submitting) return false
140
141    this.submitting = true
142    this.renderState()
143
144    const body = new FormData(this.formTarget)
145    body.delete("authenticity_token")
146    body.append("inline", "1")
147
148    try {
149      const response = await fetch(this.formTarget.action, {
150        method: "POST",
151        headers: { "X-CSRF-Token": this.csrfToken(), Accept: "application/json" },
152        body,
153        credentials: "same-origin",
154      })
155
156      this.submitting = false
157
158      if (response.status === 204) {
159        this.dirty = false
160        this.renderState()
161        return true
162      }
163
164      // Validation failed (or unexpected) — submit normally so the server can
165      // re-render the form with inline error messages.
166      this.renderState()
167      this.formTarget.requestSubmit()
168      return false
169    } catch (error) {
170      this.submitting = false
171      this.renderState()
172      this.formTarget.requestSubmit()
173      return false
174    }
175  }
176
177  csrfToken() {
178    return document.querySelector("meta[name='csrf-token']")?.content || ""
179  }
180
181  backdropClose(event) {
182    if (!this.hasDialogTarget) return
183    if (event.target === this.dialogTarget) this.stay()
184  }
185
186  beforeVisit(event) {
187    if (this.confirmedLeave) {
188      this.confirmedLeave = false
189      return
190    }
191
192    if (!this.shouldWarn()) return
193
194    const destination = event.detail?.url
195    if (!destination) return
196
197    event.preventDefault()
198    this.pendingVisitUrl = destination
199    this.dialogPurpose = "leave"
200    this.openDialog({
201      title: "Leave without saving?",
202      message: "You have unsaved changes. If you leave now, those edits will be lost.",
203      allowDiscard: true,
204      returnFocus: document.activeElement,
205    })
206  }
207
208  beforeUnload(event) {
209    if (this.confirmedLeave) return
210    if (!this.shouldWarn()) return
211
212    event.preventDefault()
213    event.returnValue = ""
214  }
215
216  shouldWarn() {
217    return this.dirty && !this.submitting
218  }
219
220  openDialog({ title = null, message = null, allowDiscard = true, returnFocus = null } = {}) {
221    if (!this.hasDialogTarget || this.dialogTarget.open) return
222    if (this.hasTitleTarget && title) this.titleTarget.textContent = title
223    if (this.hasMessageTarget && message) this.messageTarget.textContent = message
224    if (this.hasDiscardButtonTarget) this.discardButtonTarget.classList.toggle("hidden", !allowDiscard)
225    this.dialogReturnFocus = returnFocus instanceof HTMLElement ? returnFocus : null
226    this.dialogTarget.showModal()
227  }
228
229  closeDialog({ restoreFocus = true } = {}) {
230    if (!this.hasDialogTarget || !this.dialogTarget.open) return
231    const returnFocus = this.dialogReturnFocus
232    this.dialogReturnFocus = null
233    this.dialogTarget.close()
234
235    if (restoreFocus && returnFocus?.isConnected) {
236      requestAnimationFrame(() => returnFocus.focus())
237    }
238  }
239
240  renderState() {
241    this.renderDiscardTriggers()
242    if (!this.hasStatusTarget) return
243
244    this.statusTarget.classList.remove("text-slate-600", "text-amber-800")
245    this.applySaveBarState("saved")
246
247    if (this.submitting) {
248      this.statusTarget.textContent = "Saving..."
249      this.statusTarget.classList.add("text-slate-600")
250      this.applySaveBarState("submitting")
251      return
252    }
253
254    if (this.dirty) {
255      this.statusTarget.textContent = "Unsaved changes"
256      this.statusTarget.classList.add("text-amber-800")
257      this.applySaveBarState("dirty")
258      return
259    }
260
261    this.statusTarget.textContent = "All changes saved"
262    this.statusTarget.classList.add("text-slate-600")
263  }
264
265  renderDiscardTriggers() {
266    this.discardTriggerTargets.forEach((trigger) => {
267      trigger.hidden = !this.dirty
268      trigger.disabled = this.submitting
269    })
270  }
271
272  applySaveBarState(state) {
273    if (!this.hasSaveBarTarget) return
274
275    this.saveBarTarget.classList.remove(
276      "border-slate-200",
277      "bg-white/95",
278      "border-slate-300",
279      "ring-1",
280      "ring-slate-300/40",
281      "border-amber-300",
282      "bg-amber-50/95",
283      "ring-2",
284      "ring-amber-500/25"
285    )
286
287    if (state === "dirty") {
288      this.saveBarTarget.classList.add("border-amber-300", "bg-amber-50/95", "ring-2", "ring-amber-500/25")
289      return
290    }
291
292    if (state === "submitting") {
293      this.saveBarTarget.classList.add("border-slate-300", "bg-white/95", "ring-1", "ring-slate-300/40")
294      return
295    }
296
297    this.saveBarTarget.classList.add("border-slate-200", "bg-white/95")
298  }
299
300  shouldTrackDirtyEvent(event) {
301    if (!event) return true
302    if (event.type === "page-sections:changed") return true
303    if (event.type === "survey-questions:changed") return true
304    if (event.type.startsWith("trix-")) return true
305
306    const target = event.target
307    if (!(target instanceof HTMLElement)) return true
308
309    if (target.closest("[data-page-dirty-state-ignore]")) return false
310
311    if (target.matches("input[type='hidden']")) {
312      return target.dataset.dirtyTrack === "true"
313    }
314
315    return true
316  }
317}

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.