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.