/* * This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony). * * Copyright (C) 2019 - 2025 Jan Böhmer (https://github.com/jbtronics) * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published * by the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import {Controller} from "@hotwired/stimulus"; import {visit} from "@hotwired/turbo"; import * as bootbox from "bootbox"; import "../../css/components/bootbox_extensions.css"; import "../../css/components/dirty_form.css"; /** * Attach to a
element (or a wrapper containing a ) to prevent accidental navigation * away when the form has unsaved changes. * * Dirty detection is event-driven: `change` and `input` events bubble up to the form and trigger * a check of whether any element's current value differs from the DOM default recorded in the HTML * (`defaultValue` / `defaultChecked` / `option.defaultSelected`). Using both events covers both * native widgets (which fire `change`) and rich-text editors like CKEditor (which fire `input` * when they sync their underlying textarea). * * Validation failures (server returns 200 with `.is-invalid` fields) are always treated as dirty: * the submitted data was never saved, so navigating away would lose it. This removes the need for * any snapshot mechanism — the `.is-invalid` classes in the re-rendered HTML are the signal. * * Intercepts three navigation paths: * 1. Any link click (capture phase) * 2. window beforeunload * 3. turbo:before-visit * * Values: * - confirmTitle (String) – dialog title * - confirmMessage (String) – dialog body text */ export default class extends Controller { static values = { confirmTitle: {type: String, default: 'Unsaved Changes'}, confirmMessage: {type: String, default: 'You have unsaved changes. Are you sure you want to leave this page?'}, }; connect() { this._form = (this.element.tagName === 'FORM') ? this.element : this.element.querySelector('form'); this._isDirty = false; this._submitting = false; this._navigating = false; this._changeHandler = this._handleChange.bind(this); this._linkClickHandler = this._handleLinkClick.bind(this); this._beforeUnloadHandler = this._handleBeforeUnload.bind(this); this._turboBeforeVisitHandler = this._handleTurboBeforeVisit.bind(this); this._turboSubmitEndHandler = this._handleTurboSubmitEnd.bind(this); if (this._form) { this._form.addEventListener('change', this._changeHandler); // CKEditor (and other rich-text widgets) dispatch `input` rather than `change` // when their underlying textarea value is updated. this._form.addEventListener('input', this._changeHandler); } document.addEventListener('click', this._linkClickHandler, true); window.addEventListener('beforeunload', this._beforeUnloadHandler); document.addEventListener('turbo:before-visit', this._turboBeforeVisitHandler); document.addEventListener('turbo:submit-end', this._turboSubmitEndHandler); const modal = this.element.closest('.modal'); if (modal) { this._modal = modal; this._modalHideHandler = this._handleModalHide.bind(this); modal.addEventListener('hide.bs.modal', this._modalHideHandler); } } disconnect() { if (this._form) { this._form.removeEventListener('change', this._changeHandler); this._form.removeEventListener('input', this._changeHandler); } document.removeEventListener('click', this._linkClickHandler, true); window.removeEventListener('beforeunload', this._beforeUnloadHandler); document.removeEventListener('turbo:before-visit', this._turboBeforeVisitHandler); document.removeEventListener('turbo:submit-end', this._turboSubmitEndHandler); if (this._modal && this._modalHideHandler) { this._modal.removeEventListener('hide.bs.modal', this._modalHideHandler); } } /** data-action="submit->common--dirty-form#submit" — suppresses the guard while saving. */ submit() { this._submitting = true; } /** * data-action="reset->common--dirty-form#resetDirtyState" — marks the form as clean after * a programmatic reset. Native change events are not fired by form.reset(), so we set the * flag directly. Turbo also calls form.reset() internally before the post-submit redirect; * the _submitting guard prevents that from incorrectly clearing the flag. */ resetDirtyState() { if (this._submitting) return; // 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. requestAnimationFrame(() => { this._isDirty = false; this._clearDirtyMarkers(); }); } _handleChange(event) { const target = event?.target; if (target?.name) { this._updateDirtyMarker(target); } else { this._refreshDirtyMarkers(); } this._isDirty = this._form?.querySelector('[data-dirty]') !== null; } /** * Walk every named form element and update its `data-dirty` attribute. * Un-named elements (e.g. the visible TristateCheckbox whose name was removed) are * skipped — they are not submitted and are not the source of truth for form data. */ _refreshDirtyMarkers() { if (!this._form) return; for (const el of this._form.elements) { if (!el.name) continue; this._updateDirtyMarker(el); } } /** * Set or clear `data-dirty` on a single named form element. * Hidden inputs are not visually rendered, so special handling applies: * - TristateCheckbox: the hidden backing input is preceded by a nameless visual checkbox — * mark that instead. * - Other hidden inputs (e.g. CSRF tokens): ignored. * TomSelect hides the