|
| 1 | +/* |
| 2 | + * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). |
| 3 | + * |
| 4 | + * Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics) |
| 5 | + * |
| 6 | + * This program is free software: you can redistribute it and/or modify |
| 7 | + * it under the terms of the GNU Affero General Public License as published |
| 8 | + * by the Free Software Foundation, either version 3 of the License, or |
| 9 | + * (at your option) any later version. |
| 10 | + * |
| 11 | + * This program is distributed in the hope that it will be useful, |
| 12 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 13 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 14 | + * GNU Affero General Public License for more details. |
| 15 | + * |
| 16 | + * You should have received a copy of the GNU Affero General Public License |
| 17 | + * along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 18 | + */ |
| 19 | + |
| 20 | +import {Controller} from "@hotwired/stimulus"; |
| 21 | +import {visit} from "@hotwired/turbo"; |
| 22 | +import * as bootbox from "bootbox"; |
| 23 | +import "../../css/components/bootbox_extensions.css"; |
| 24 | +import "../../css/components/dirty_form.css"; |
| 25 | + |
| 26 | +/** |
| 27 | + * Attach to a <form> element (or a wrapper containing a <form>) to prevent accidental navigation |
| 28 | + * away when the form has unsaved changes. |
| 29 | + * |
| 30 | + * Dirty detection is event-driven: `change` and `input` events bubble up to the form and trigger |
| 31 | + * a check of whether any element's current value differs from the DOM default recorded in the HTML |
| 32 | + * (`defaultValue` / `defaultChecked` / `option.defaultSelected`). Using both events covers both |
| 33 | + * native widgets (which fire `change`) and rich-text editors like CKEditor (which fire `input` |
| 34 | + * when they sync their underlying textarea). |
| 35 | + * |
| 36 | + * Validation failures (server returns 200 with `.is-invalid` fields) are always treated as dirty: |
| 37 | + * the submitted data was never saved, so navigating away would lose it. This removes the need for |
| 38 | + * any snapshot mechanism — the `.is-invalid` classes in the re-rendered HTML are the signal. |
| 39 | + * |
| 40 | + * Intercepts three navigation paths: |
| 41 | + * 1. Any <a href> link click (capture phase) |
| 42 | + * 2. window beforeunload |
| 43 | + * 3. turbo:before-visit |
| 44 | + * |
| 45 | + * Values: |
| 46 | + * - confirmTitle (String) – dialog title |
| 47 | + * - confirmMessage (String) – dialog body text |
| 48 | + */ |
| 49 | +export default class extends Controller { |
| 50 | + static values = { |
| 51 | + confirmTitle: {type: String, default: 'Unsaved Changes'}, |
| 52 | + confirmMessage: {type: String, default: 'You have unsaved changes. Are you sure you want to leave this page?'}, |
| 53 | + }; |
| 54 | + |
| 55 | + connect() { |
| 56 | + this._form = (this.element.tagName === 'FORM') ? this.element : this.element.querySelector('form'); |
| 57 | + this._isDirty = false; |
| 58 | + this._submitting = false; |
| 59 | + this._navigating = false; |
| 60 | + |
| 61 | + this._changeHandler = this._handleChange.bind(this); |
| 62 | + this._linkClickHandler = this._handleLinkClick.bind(this); |
| 63 | + this._beforeUnloadHandler = this._handleBeforeUnload.bind(this); |
| 64 | + this._turboBeforeVisitHandler = this._handleTurboBeforeVisit.bind(this); |
| 65 | + this._turboSubmitEndHandler = this._handleTurboSubmitEnd.bind(this); |
| 66 | + |
| 67 | + if (this._form) { |
| 68 | + this._form.addEventListener('change', this._changeHandler); |
| 69 | + // CKEditor (and other rich-text widgets) dispatch `input` rather than `change` |
| 70 | + // when their underlying textarea value is updated. |
| 71 | + this._form.addEventListener('input', this._changeHandler); |
| 72 | + } |
| 73 | + document.addEventListener('click', this._linkClickHandler, true); |
| 74 | + window.addEventListener('beforeunload', this._beforeUnloadHandler); |
| 75 | + document.addEventListener('turbo:before-visit', this._turboBeforeVisitHandler); |
| 76 | + document.addEventListener('turbo:submit-end', this._turboSubmitEndHandler); |
| 77 | + |
| 78 | + const modal = this.element.closest('.modal'); |
| 79 | + if (modal) { |
| 80 | + this._modal = modal; |
| 81 | + this._modalHideHandler = this._handleModalHide.bind(this); |
| 82 | + modal.addEventListener('hide.bs.modal', this._modalHideHandler); |
| 83 | + } |
| 84 | + } |
| 85 | + |
| 86 | + disconnect() { |
| 87 | + if (this._form) { |
| 88 | + this._form.removeEventListener('change', this._changeHandler); |
| 89 | + this._form.removeEventListener('input', this._changeHandler); |
| 90 | + } |
| 91 | + document.removeEventListener('click', this._linkClickHandler, true); |
| 92 | + window.removeEventListener('beforeunload', this._beforeUnloadHandler); |
| 93 | + document.removeEventListener('turbo:before-visit', this._turboBeforeVisitHandler); |
| 94 | + document.removeEventListener('turbo:submit-end', this._turboSubmitEndHandler); |
| 95 | + |
| 96 | + if (this._modal && this._modalHideHandler) { |
| 97 | + this._modal.removeEventListener('hide.bs.modal', this._modalHideHandler); |
| 98 | + } |
| 99 | + } |
| 100 | + |
| 101 | + /** data-action="submit->common--dirty-form#submit" — suppresses the guard while saving. */ |
| 102 | + submit() { |
| 103 | + this._submitting = true; |
| 104 | + } |
| 105 | + |
| 106 | + /** |
| 107 | + * data-action="reset->common--dirty-form#resetDirtyState" — marks the form as clean after |
| 108 | + * a programmatic reset. Native change events are not fired by form.reset(), so we set the |
| 109 | + * flag directly. Turbo also calls form.reset() internally before the post-submit redirect; |
| 110 | + * the _submitting guard prevents that from incorrectly clearing the flag. |
| 111 | + */ |
| 112 | + resetDirtyState() { |
| 113 | + if (this._submitting) return; |
| 114 | + |
| 115 | + // Wait for a frame to allow the form's DOM state to update after the reset() call, then refresh markers and update the dirty flag. |
| 116 | + requestAnimationFrame(() => { |
| 117 | + this._isDirty = false; |
| 118 | + this._clearDirtyMarkers(); |
| 119 | + }); |
| 120 | + } |
| 121 | + |
| 122 | + _handleChange(event) { |
| 123 | + const target = event?.target; |
| 124 | + if (target?.name) { |
| 125 | + this._updateDirtyMarker(target); |
| 126 | + } else { |
| 127 | + this._refreshDirtyMarkers(); |
| 128 | + } |
| 129 | + this._isDirty = this._form?.querySelector('[data-dirty]') !== null; |
| 130 | + } |
| 131 | + |
| 132 | + /** |
| 133 | + * Walk every named form element and update its `data-dirty` attribute. |
| 134 | + * Un-named elements (e.g. the visible TristateCheckbox whose name was removed) are |
| 135 | + * skipped — they are not submitted and are not the source of truth for form data. |
| 136 | + */ |
| 137 | + _refreshDirtyMarkers() { |
| 138 | + if (!this._form) return; |
| 139 | + for (const el of this._form.elements) { |
| 140 | + if (!el.name) continue; |
| 141 | + this._updateDirtyMarker(el); |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + /** |
| 146 | + * Set or clear `data-dirty` on a single named form element. |
| 147 | + * Hidden inputs are not visually rendered, so special handling applies: |
| 148 | + * - TristateCheckbox: the hidden backing input is preceded by a nameless visual checkbox — |
| 149 | + * mark that instead. |
| 150 | + * - Other hidden inputs (e.g. CSRF tokens): ignored. |
| 151 | + * TomSelect hides the <select> before .ts-wrapper (sibling); CSS targets .ts-control via the |
| 152 | + * adjacent-sibling combinator on the select's data-dirty attribute. |
| 153 | + */ |
| 154 | + _updateDirtyMarker(el) { |
| 155 | + if (el.type === 'hidden') { |
| 156 | + const visual = el.previousElementSibling; |
| 157 | + if (visual instanceof HTMLInputElement && !visual.name) { |
| 158 | + visual.toggleAttribute('data-dirty', el.value !== el.defaultValue); |
| 159 | + } |
| 160 | + return; |
| 161 | + } |
| 162 | + |
| 163 | + const dirty = this._isElementDirty(el); |
| 164 | + el.toggleAttribute('data-dirty', dirty); |
| 165 | + } |
| 166 | + |
| 167 | + _clearDirtyMarkers() { |
| 168 | + this._form?.querySelectorAll('[data-dirty]').forEach(el => el.removeAttribute('data-dirty')); |
| 169 | + } |
| 170 | + |
| 171 | + _isElementDirty(el) { |
| 172 | + //Disabled elements are not editable, so ignore them even if their value differs from the default. |
| 173 | + if (el.disabled) return false; |
| 174 | + |
| 175 | + if (el.type === 'file') return false; |
| 176 | + if (el.type === 'checkbox' || el.type === 'radio') { |
| 177 | + return el.checked !== el.defaultChecked; |
| 178 | + } |
| 179 | + if (el.tagName === 'SELECT') { |
| 180 | + // TomSelect sets data-default-value to the value at init time. |
| 181 | + // The native option.defaultSelected approach is unreliable when no option |
| 182 | + // carries the `selected` attribute — the browser auto-selects option[0] |
| 183 | + // (selected=true) while defaultSelected stays false, causing a false positive. |
| 184 | + if (el.dataset.defaultValue !== undefined) { |
| 185 | + return el.value !== el.dataset.defaultValue; |
| 186 | + } |
| 187 | + for (const option of el.options) { |
| 188 | + if (option.selected !== option.defaultSelected) return true; |
| 189 | + } |
| 190 | + return false; |
| 191 | + } |
| 192 | + |
| 193 | + let defaultValue = el.defaultValue; |
| 194 | + |
| 195 | + //If an element has an data-default-value, use that for dirty checking instead of the DOM default Value. Set for example by the ckeditor-controller |
| 196 | + if (el.dataset.defaultValue !== undefined) { |
| 197 | + defaultValue = el.dataset.defaultValue; |
| 198 | + } |
| 199 | + return el.value !== defaultValue; |
| 200 | + } |
| 201 | + |
| 202 | + _isFormDirty() { |
| 203 | + if (this._submitting) return false; |
| 204 | + // A form with validation errors was submitted but never saved — always treat as dirty. |
| 205 | + if (this._form?.querySelector('.is-invalid')) return true; |
| 206 | + return this._isDirty; |
| 207 | + } |
| 208 | + |
| 209 | + _confirmNavigation(onConfirm) { |
| 210 | + bootbox.confirm({ |
| 211 | + title: this.confirmTitleValue, |
| 212 | + message: this.confirmMessageValue, |
| 213 | + callback: (result) => { if (result) onConfirm(); } |
| 214 | + }); |
| 215 | + } |
| 216 | + |
| 217 | + _handleLinkClick(event) { |
| 218 | + if (this._navigating) return; |
| 219 | + |
| 220 | + const link = event.target.closest('a[href]'); |
| 221 | + if (!link) return; |
| 222 | + |
| 223 | + const href = link.getAttribute('href'); |
| 224 | + if (!href || href.startsWith('#')) return; |
| 225 | + if (link.target === '_blank' || link.target === '_top' || link.target === '_parent') return; |
| 226 | + if (link.hasAttribute('data-dirty-form-ignore')) return; |
| 227 | + |
| 228 | + if (!this._isFormDirty()) return; |
| 229 | + |
| 230 | + event.preventDefault(); |
| 231 | + event.stopPropagation(); |
| 232 | + this._confirmNavigation(() => { this._navigating = true; link.click(); }); |
| 233 | + } |
| 234 | + |
| 235 | + _handleBeforeUnload(event) { |
| 236 | + if (this._navigating || !this._isFormDirty()) return; |
| 237 | + event.preventDefault(); |
| 238 | + event.returnValue = ''; |
| 239 | + } |
| 240 | + |
| 241 | + _handleTurboBeforeVisit(event) { |
| 242 | + if (this._navigating || !this._isFormDirty()) return; |
| 243 | + |
| 244 | + event.preventDefault(); |
| 245 | + const url = event.detail.url; |
| 246 | + const frame = event.detail.frame; |
| 247 | + this._confirmNavigation(() => { |
| 248 | + this._navigating = true; |
| 249 | + if (frame) { window.Turbo.visit(url, { frame }); } else { visit(url); } |
| 250 | + }); |
| 251 | + } |
| 252 | + |
| 253 | + _handleTurboSubmitEnd(event) { |
| 254 | + const submittedForm = event.detail?.formSubmission?.formElement; |
| 255 | + if (submittedForm !== this._form) return; |
| 256 | + |
| 257 | + // For a successful save (redirect), the controller will disconnect with the Turbo |
| 258 | + // navigation; reset is only needed for validation errors where the form stays in the DOM. |
| 259 | + const savedSuccessfully = event.detail.success && event.detail.fetchResponse?.redirected; |
| 260 | + if (!savedSuccessfully) { |
| 261 | + this._submitting = false; |
| 262 | + } |
| 263 | + } |
| 264 | + |
| 265 | + _handleModalHide(event) { |
| 266 | + if (this._navigating || !this._isFormDirty()) return; |
| 267 | + |
| 268 | + event.preventDefault(); |
| 269 | + this._confirmNavigation(() => { |
| 270 | + this._navigating = true; |
| 271 | + window.bootstrap?.Modal?.getInstance(this._modal)?.hide(); |
| 272 | + }); |
| 273 | + } |
| 274 | +} |
0 commit comments