Skip to content

Commit 79c3649

Browse files
committed
Added a "unsaved changed" warning dialog for part, entity edits and system settings
This fixes issue #1368
1 parent ad0c60f commit 79c3649

21 files changed

Lines changed: 556 additions & 11 deletions
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
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+
}

assets/controllers/elements/ai_model_autocomplete_controller.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ import TomSelect from "tom-select";
2525

2626
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
2727
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
28+
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
2829

2930
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
3031
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
32+
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
3133

3234
export default class extends Controller {
3335
_tomSelect;
@@ -82,7 +84,8 @@ export default class extends Controller {
8284
'autoselect_typed': {},
8385
'click_to_edit': {},
8486
'clear_button': {},
85-
"restore_on_backspace": {}
87+
'restore_on_backspace': {},
88+
'form_reset_handler': {}
8689
}
8790
};
8891

assets/controllers/elements/attachment_autocomplete_controller.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ import TomSelect from "tom-select";
2525

2626
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
2727
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
28+
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
2829

2930
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
3031
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
32+
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
3133

3234
export default class extends Controller {
3335
_tomSelect;
@@ -64,7 +66,8 @@ export default class extends Controller {
6466
'autoselect_typed': {},
6567
'click_to_edit': {},
6668
'clear_button': {},
67-
"restore_on_backspace": {}
69+
'restore_on_backspace': {},
70+
'form_reset_handler': {}
6871
}
6972
};
7073

assets/controllers/elements/ckeditor_controller.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,9 @@ export default class extends Controller {
9191
config.translations = [window.CKEDITOR_TRANSLATIONS, translations];
9292
}
9393

94+
//Apply the default value of the source element as data attribute, so that dirty-form-controller can detect changes
95+
this.element.dataset.defaultValue = this.element.defaultValue;
96+
9497
const watchdog = new EditorWatchdog();
9598
watchdog.setCreator((elementOrData, editorConfig) => {
9699
return EDITOR_TYPE.create(elementOrData, editorConfig)
@@ -111,10 +114,21 @@ export default class extends Controller {
111114
editor.updateSourceElement();
112115

113116
// Dispatch the input event for further treatment
114-
const event = new Event("input");
115-
this.element.dispatchEvent(event);
117+
this.element.dispatchEvent(new Event("input", { bubbles: true }));
116118
});
117119

120+
//Set an reset listener to update the editor if the source element is reset (e.g. by a reset button)
121+
if (this.element.form && this.element.name) {
122+
this.element.form.addEventListener("reset", () => {
123+
if (editor.isReadOnly) {
124+
return;
125+
}
126+
if (this.element.dataset.defaultValue !== undefined) {
127+
editor.setData(this.element.dataset.defaultValue);
128+
}
129+
});
130+
}
131+
118132
//This return is important! Otherwise we get mysterious errors in the console
119133
//See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302
120134
return editor;

assets/controllers/elements/part_select_controller.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
44
import '../../css/components/tom-select_extensions.css';
55
import TomSelect from "tom-select";
66
import {marked} from "marked";
7+
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
8+
9+
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
710

811
export default class extends Controller {
912
_tomSelect;
@@ -18,7 +21,7 @@ export default class extends Controller {
1821

1922
let settings = {
2023
allowEmptyOption: true,
21-
plugins: ['dropdown_input', this.element.required ? null : 'clear_button'],
24+
plugins: ['dropdown_input', this.element.required ? null : 'clear_button', 'form_reset_handler'],
2225
searchField: ["name", "description", "category", "footprint"],
2326
valueField: "id",
2427
labelField: "name",

assets/controllers/elements/select_controller.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import {Controller} from "@hotwired/stimulus";
2121
import "tom-select/dist/css/tom-select.bootstrap5.css";
2222
import '../../css/components/tom-select_extensions.css';
2323
import TomSelect from "tom-select";
24+
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
25+
26+
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
2427

2528
export default class extends Controller {
2629

@@ -44,7 +47,7 @@ export default class extends Controller {
4447
}
4548

4649
let settings = {
47-
plugins: ["clear_button"],
50+
plugins: ["clear_button", "form_reset_handler"],
4851
allowEmptyOption: true,
4952
selectOnTab: true,
5053
maxOptions: null,

0 commit comments

Comments
 (0)