mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-28 06:11:48 +00:00
Added a "unsaved changed" warning dialog for part, entity edits and system settings
This fixes issue #1368
This commit is contained in:
parent
ad0c60f766
commit
79c36494ea
21 changed files with 556 additions and 11 deletions
274
assets/controllers/common/dirty_form_controller.js
Normal file
274
assets/controllers/common/dirty_form_controller.js
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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 <form> element (or a wrapper containing a <form>) 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 <a href> 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 <select> before .ts-wrapper (sibling); CSS targets .ts-control via the
|
||||
* adjacent-sibling combinator on the select's data-dirty attribute.
|
||||
*/
|
||||
_updateDirtyMarker(el) {
|
||||
if (el.type === 'hidden') {
|
||||
const visual = el.previousElementSibling;
|
||||
if (visual instanceof HTMLInputElement && !visual.name) {
|
||||
visual.toggleAttribute('data-dirty', el.value !== el.defaultValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const dirty = this._isElementDirty(el);
|
||||
el.toggleAttribute('data-dirty', dirty);
|
||||
}
|
||||
|
||||
_clearDirtyMarkers() {
|
||||
this._form?.querySelectorAll('[data-dirty]').forEach(el => el.removeAttribute('data-dirty'));
|
||||
}
|
||||
|
||||
_isElementDirty(el) {
|
||||
//Disabled elements are not editable, so ignore them even if their value differs from the default.
|
||||
if (el.disabled) return false;
|
||||
|
||||
if (el.type === 'file') return false;
|
||||
if (el.type === 'checkbox' || el.type === 'radio') {
|
||||
return el.checked !== el.defaultChecked;
|
||||
}
|
||||
if (el.tagName === 'SELECT') {
|
||||
// TomSelect sets data-default-value to the value at init time.
|
||||
// The native option.defaultSelected approach is unreliable when no option
|
||||
// carries the `selected` attribute — the browser auto-selects option[0]
|
||||
// (selected=true) while defaultSelected stays false, causing a false positive.
|
||||
if (el.dataset.defaultValue !== undefined) {
|
||||
return el.value !== el.dataset.defaultValue;
|
||||
}
|
||||
for (const option of el.options) {
|
||||
if (option.selected !== option.defaultSelected) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
let defaultValue = el.defaultValue;
|
||||
|
||||
//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
|
||||
if (el.dataset.defaultValue !== undefined) {
|
||||
defaultValue = el.dataset.defaultValue;
|
||||
}
|
||||
return el.value !== defaultValue;
|
||||
}
|
||||
|
||||
_isFormDirty() {
|
||||
if (this._submitting) return false;
|
||||
// A form with validation errors was submitted but never saved — always treat as dirty.
|
||||
if (this._form?.querySelector('.is-invalid')) return true;
|
||||
return this._isDirty;
|
||||
}
|
||||
|
||||
_confirmNavigation(onConfirm) {
|
||||
bootbox.confirm({
|
||||
title: this.confirmTitleValue,
|
||||
message: this.confirmMessageValue,
|
||||
callback: (result) => { if (result) onConfirm(); }
|
||||
});
|
||||
}
|
||||
|
||||
_handleLinkClick(event) {
|
||||
if (this._navigating) return;
|
||||
|
||||
const link = event.target.closest('a[href]');
|
||||
if (!link) return;
|
||||
|
||||
const href = link.getAttribute('href');
|
||||
if (!href || href.startsWith('#')) return;
|
||||
if (link.target === '_blank' || link.target === '_top' || link.target === '_parent') return;
|
||||
if (link.hasAttribute('data-dirty-form-ignore')) return;
|
||||
|
||||
if (!this._isFormDirty()) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this._confirmNavigation(() => { this._navigating = true; link.click(); });
|
||||
}
|
||||
|
||||
_handleBeforeUnload(event) {
|
||||
if (this._navigating || !this._isFormDirty()) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
|
||||
_handleTurboBeforeVisit(event) {
|
||||
if (this._navigating || !this._isFormDirty()) return;
|
||||
|
||||
event.preventDefault();
|
||||
const url = event.detail.url;
|
||||
const frame = event.detail.frame;
|
||||
this._confirmNavigation(() => {
|
||||
this._navigating = true;
|
||||
if (frame) { window.Turbo.visit(url, { frame }); } else { visit(url); }
|
||||
});
|
||||
}
|
||||
|
||||
_handleTurboSubmitEnd(event) {
|
||||
const submittedForm = event.detail?.formSubmission?.formElement;
|
||||
if (submittedForm !== this._form) return;
|
||||
|
||||
// For a successful save (redirect), the controller will disconnect with the Turbo
|
||||
// navigation; reset is only needed for validation errors where the form stays in the DOM.
|
||||
const savedSuccessfully = event.detail.success && event.detail.fetchResponse?.redirected;
|
||||
if (!savedSuccessfully) {
|
||||
this._submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
_handleModalHide(event) {
|
||||
if (this._navigating || !this._isFormDirty()) return;
|
||||
|
||||
event.preventDefault();
|
||||
this._confirmNavigation(() => {
|
||||
this._navigating = true;
|
||||
window.bootstrap?.Modal?.getInstance(this._modal)?.hide();
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue