Added a "unsaved changed" warning dialog for part, entity edits and system settings

This fixes issue #1368
This commit is contained in:
Jan Böhmer 2026-05-25 21:29:10 +02:00
parent ad0c60f766
commit 79c36494ea
21 changed files with 556 additions and 11 deletions

View 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();
});
}
}

View file

@ -25,9 +25,11 @@ import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit' import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed' import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit) TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed) TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller { export default class extends Controller {
_tomSelect; _tomSelect;
@ -82,7 +84,8 @@ export default class extends Controller {
'autoselect_typed': {}, 'autoselect_typed': {},
'click_to_edit': {}, 'click_to_edit': {},
'clear_button': {}, 'clear_button': {},
"restore_on_backspace": {} 'restore_on_backspace': {},
'form_reset_handler': {}
} }
}; };

View file

@ -25,9 +25,11 @@ import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit' import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed' import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit) TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed) TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller { export default class extends Controller {
_tomSelect; _tomSelect;
@ -64,7 +66,8 @@ export default class extends Controller {
'autoselect_typed': {}, 'autoselect_typed': {},
'click_to_edit': {}, 'click_to_edit': {},
'clear_button': {}, 'clear_button': {},
"restore_on_backspace": {} 'restore_on_backspace': {},
'form_reset_handler': {}
} }
}; };

View file

@ -91,6 +91,9 @@ export default class extends Controller {
config.translations = [window.CKEDITOR_TRANSLATIONS, translations]; config.translations = [window.CKEDITOR_TRANSLATIONS, translations];
} }
//Apply the default value of the source element as data attribute, so that dirty-form-controller can detect changes
this.element.dataset.defaultValue = this.element.defaultValue;
const watchdog = new EditorWatchdog(); const watchdog = new EditorWatchdog();
watchdog.setCreator((elementOrData, editorConfig) => { watchdog.setCreator((elementOrData, editorConfig) => {
return EDITOR_TYPE.create(elementOrData, editorConfig) return EDITOR_TYPE.create(elementOrData, editorConfig)
@ -111,10 +114,21 @@ export default class extends Controller {
editor.updateSourceElement(); editor.updateSourceElement();
// Dispatch the input event for further treatment // Dispatch the input event for further treatment
const event = new Event("input"); this.element.dispatchEvent(new Event("input", { bubbles: true }));
this.element.dispatchEvent(event);
}); });
//Set an reset listener to update the editor if the source element is reset (e.g. by a reset button)
if (this.element.form && this.element.name) {
this.element.form.addEventListener("reset", () => {
if (editor.isReadOnly) {
return;
}
if (this.element.dataset.defaultValue !== undefined) {
editor.setData(this.element.dataset.defaultValue);
}
});
}
//This return is important! Otherwise we get mysterious errors in the console //This return is important! Otherwise we get mysterious errors in the console
//See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302 //See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302
return editor; return editor;

View file

@ -4,6 +4,9 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css'; import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select"; import TomSelect from "tom-select";
import {marked} from "marked"; import {marked} from "marked";
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller { export default class extends Controller {
_tomSelect; _tomSelect;
@ -18,7 +21,7 @@ export default class extends Controller {
let settings = { let settings = {
allowEmptyOption: true, allowEmptyOption: true,
plugins: ['dropdown_input', this.element.required ? null : 'clear_button'], plugins: ['dropdown_input', this.element.required ? null : 'clear_button', 'form_reset_handler'],
searchField: ["name", "description", "category", "footprint"], searchField: ["name", "description", "category", "footprint"],
valueField: "id", valueField: "id",
labelField: "name", labelField: "name",

View file

@ -21,6 +21,9 @@ import {Controller} from "@hotwired/stimulus";
import "tom-select/dist/css/tom-select.bootstrap5.css"; import "tom-select/dist/css/tom-select.bootstrap5.css";
import '../../css/components/tom-select_extensions.css'; import '../../css/components/tom-select_extensions.css';
import TomSelect from "tom-select"; import TomSelect from "tom-select";
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller { export default class extends Controller {
@ -44,7 +47,7 @@ export default class extends Controller {
} }
let settings = { let settings = {
plugins: ["clear_button"], plugins: ["clear_button", "form_reset_handler"],
allowEmptyOption: true, allowEmptyOption: true,
selectOnTab: true, selectOnTab: true,
maxOptions: null, maxOptions: null,

View file

@ -25,9 +25,11 @@ import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit' import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed' import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit) TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed) TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
/** /**
* This is the frontend controller for StaticFileAutocompleteType form element. * This is the frontend controller for StaticFileAutocompleteType form element.
@ -64,7 +66,8 @@ export default class extends Controller {
'autoselect_typed': {}, 'autoselect_typed': {},
'click_to_edit': {}, 'click_to_edit': {},
'clear_button': {}, 'clear_button': {},
'restore_on_backspace': {} 'restore_on_backspace': {},
'form_reset_handler': {}
} }
}; };

View file

@ -23,9 +23,11 @@ import TomSelect from "tom-select";
import {Controller} from "@hotwired/stimulus"; import {Controller} from "@hotwired/stimulus";
import {trans} from '../../translator.js' import {trans} from '../../translator.js'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed' import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed) TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller { export default class extends Controller {
_tomSelect; _tomSelect;
@ -96,6 +98,7 @@ export default class extends Controller {
plugins: { plugins: {
"autoselect_typed": {}, "autoselect_typed": {},
"form_reset_handler": {},
} }
}; };
@ -105,6 +108,7 @@ export default class extends Controller {
} }
this._tomSelect = new TomSelect(this.element, settings); this._tomSelect = new TomSelect(this.element, settings);
//Do not do a sync here as this breaks the initial rendering of the empty option //Do not do a sync here as this breaks the initial rendering of the empty option
//this._tomSelect.sync(); //this._tomSelect.sync();
} }

View file

@ -25,9 +25,11 @@ import TomSelect from "tom-select";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit' import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed' import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit) TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed) TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
export default class extends Controller { export default class extends Controller {
_tomSelect; _tomSelect;
@ -43,6 +45,7 @@ export default class extends Controller {
remove_button:{}, remove_button:{},
'autoselect_typed': {}, 'autoselect_typed': {},
'click_to_edit': {}, 'click_to_edit': {},
'form_reset_handler': {},
}, },
persistent: false, persistent: false,
selectOnTab: true, selectOnTab: true,

View file

@ -102,7 +102,18 @@ export default class extends Controller {
onNodeSelected: (event) => { onNodeSelected: (event) => {
const node = event.detail.node; const node = event.detail.node;
if (node.href) { if (node.href) {
window.Turbo.visit(node.href, {action: "advance", frame: this._frame}); const url = node.href;
// Turbo.visit with a frame target bypasses turbo:before-visit, so dispatch it
// manually so that dirty-form guards can intercept it.
const beforeVisitEvent = new CustomEvent('turbo:before-visit', {
bubbles: true,
cancelable: true,
detail: { url, frame: this._frame },
});
document.dispatchEvent(beforeVisitEvent);
if (!beforeVisitEvent.defaultPrevented) {
window.Turbo.visit(url, {action: "advance", frame: this._frame});
}
} }
}, },
}, [BS5Theme, BS53Theme, FAIconTheme]); }, [BS5Theme, BS53Theme, FAIconTheme]);

View file

@ -25,9 +25,11 @@ import "katex/dist/katex.css";
import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit' import TomSelect_click_to_edit from '../../tomselect/click_to_edit/click_to_edit'
import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed' import TomSelect_autoselect_typed from '../../tomselect/autoselect_typed/autoselect_typed'
import TomSelect_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
TomSelect.define('click_to_edit', TomSelect_click_to_edit) TomSelect.define('click_to_edit', TomSelect_click_to_edit)
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed) TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
/* stimulusFetch: 'lazy' */ /* stimulusFetch: 'lazy' */
export default class extends Controller export default class extends Controller
@ -63,7 +65,8 @@ export default class extends Controller
'autoselect_typed': {}, 'autoselect_typed': {},
'click_to_edit': {}, 'click_to_edit': {},
'clear_button': {}, 'clear_button': {},
'restore_on_backspace': {} 'restore_on_backspace': {},
'form_reset_handler': {}
}, },
persistent: false, persistent: false,
maxItems: 1, maxItems: 1,

View file

@ -0,0 +1,61 @@
/*
* 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/>.
*/
/* Standard Bootstrap form controls */
.form-control[data-dirty],
.form-select[data-dirty],
input[data-dirty] + .form-control,
select[data-dirty] + .form-select
{
border-color: var(--bs-info);
border-width: 2px;
}
.form-control[data-dirty]:focus,
.form-select[data-dirty]:focus,
input[data-dirty] + .form-control:focus,
select[data-dirty] + .form-select:focus
{
border-color: var(--bs-info);
border-width: 2px;
box-shadow: 0 0 0 0.25rem rgba(var(--bs-info-rgb), 0.25);
}
/* Checkboxes and radios */
.form-check-input[data-dirty] {
border-color: var(--bs-info);
border-width: 2px;
}
.form-check-input[data-dirty]:checked {
background-color: var(--bs-info);
border-color: var(--bs-info);
border-width: 2px;
}
/* CKEditor: the editable sits after the hidden source textarea in the DOM */
textarea[data-dirty] ~ .ck.ck-editor .ck-editor__editable:not(.ck-focused) {
border-color: var(--bs-info) !important;
border-width: 2px !important;
}
textarea[data-dirty] ~ .ck.ck-editor .ck-editor__editable.ck-focused {
border-width: 2px !important;
box-shadow: 0 0 0 0.25rem rgba(var(--bs-info-rgb), 0.25) !important;
}

View file

@ -83,6 +83,8 @@ export default class TristateCheckbox {
//Do a refresh to set the correct styling of the checkbox //Do a refresh to set the correct styling of the checkbox
this._refresh(); this._refresh();
// Anchor the hidden input's default so dirty-form comparisons have a clean baseline.
this._hiddenInput.defaultValue = this._hiddenInput.value;
this._element.addEventListener('click', this.click.bind(this)); this._element.addEventListener('click', this.click.bind(this));
} }
@ -202,6 +204,9 @@ export default class TristateCheckbox {
} }
this._refresh(); this._refresh();
// Notify change listeners (e.g. dirty-form controller) since programmatic
// value changes don't fire native change events.
this._hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
} }
} }

View file

@ -56,6 +56,7 @@ class TristateHelper {
document.addEventListener("turbo:load", listener); document.addEventListener("turbo:load", listener);
document.addEventListener("turbo:render", listener); document.addEventListener("turbo:render", listener);
document.addEventListener("turbo:frame-load", listener);
document.addEventListener("collection:elementAdded", listener); document.addEventListener("collection:elementAdded", listener);
} }
} }

View file

@ -0,0 +1,46 @@
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2026 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/>.
*/
/**
* TomSelect plugin for dirty-check integration and form reset support.
*
* Sets data-default-value on the underlying <select> so the dirty-check
* controller can detect changes, and restores that value when the form is reset.
*/
export default function form_reset_handler() {
const self = this;
const input = this.input;
// Multiple selects not yet supported
if (input.multiple) {
return;
}
// Always capture the initial value, even empty string.
// Empty string is falsy, so the old `|| null` guard would silently skip it,
// leaving data-default-value unset and breaking the dirty check for blank defaults.
input.dataset.defaultValue = input.value;
if (input.form) {
input.form.addEventListener('reset', () => {
input.value = input.dataset.defaultValue ?? '';
self.sync();
});
}
}

View file

@ -23,6 +23,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use Symfony\Component\Form\Extension\Core\Type\ResetType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType; use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use App\Settings\AppSettings; use App\Settings\AppSettings;
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface; use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
@ -50,7 +51,9 @@ class SettingsController extends AbstractController
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class); $settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);
//Create a form builder for the settings object //Create a form builder for the settings object
$builder = $this->settingsFormFactory->createSettingsFormBuilder($settings); $builder = $this->settingsFormFactory->createSettingsFormBuilder($settings, formOptions: [
'warn_on_unsaved_changes' => true,
]);
//Add a submit button to the form //Add a submit button to the form
$builder->add('submit', SubmitType::class, ['label' => 'save']); $builder->add('submit', SubmitType::class, ['label' => 'save']);

View file

@ -57,6 +57,10 @@ class BaseEntityAdminForm extends AbstractType
$resolver->setRequired('attachment_class'); $resolver->setRequired('attachment_class');
$resolver->setRequired('parameter_class'); $resolver->setRequired('parameter_class');
$resolver->setAllowedTypes('parameter_class', ['string', 'null']); $resolver->setAllowedTypes('parameter_class', ['string', 'null']);
$resolver->setDefaults([
'warn_on_unsaved_changes' => true,
]);
} }
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void

View file

@ -0,0 +1,84 @@
<?php
/*
* 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/>.
*/
declare(strict_types=1);
namespace App\Form\Extension;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormView;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Contracts\Translation\TranslatorInterface;
/**
* Adds a `warn_on_unsaved_changes` option to any root form. When set to true, the Stimulus
* `common--dirty-form` controller attributes are merged into the form element's HTML
* attributes, enabling unsaved-change detection without any template boilerplate.
*
* Usage in a form type:
*
* public function configureOptions(OptionsResolver $resolver): void
* {
* $resolver->setDefaults(['warn_on_unsaved_changes' => true]);
* }
*
* Or per-instance from a controller:
*
* $form = $this->createForm(MyFormType::class, $data, ['warn_on_unsaved_changes' => true]);
*/
class UnsavedChangesExtension extends AbstractTypeExtension
{
public function __construct(private readonly TranslatorInterface $translator)
{
}
public static function getExtendedTypes(): iterable
{
return [FormType::class];
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('warn_on_unsaved_changes', false);
$resolver->setAllowedTypes('warn_on_unsaved_changes', 'bool');
}
public function buildView(FormView $view, FormInterface $form, array $options): void
{
if (!$options['warn_on_unsaved_changes'] || $view->parent !== null) {
return;
}
$extraAttr = [
'data-controller' => 'common--dirty-form',
'data-common--dirty-form-confirm-title-value' => $this->translator->trans('form.dirty_form.unsaved_changes.title'),
'data-common--dirty-form-confirm-message-value' => $this->translator->trans('form.dirty_form.unsaved_changes.message'),
];
// Merge data-action so existing actions on the form element are preserved.
$existingAction = $view->vars['attr']['data-action'] ?? '';
$dirtyActions = 'submit->common--dirty-form#submit reset->common--dirty-form#resetDirtyState';
$extraAttr['data-action'] = $existingAction !== '' ? $existingAction . ' ' . $dirtyActions : $dirtyActions;
$view->vars['attr'] = array_merge($view->vars['attr'], $extraAttr);
}
}

View file

@ -353,6 +353,7 @@ class PartBaseType extends AbstractType
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => Part::class, 'data_class' => Part::class,
'info_provider_dto' => null, 'info_provider_dto' => null,
'warn_on_unsaved_changes' => true,
]); ]);
$resolver->setAllowedTypes('info_provider_dto', [PartDetailDTO::class, 'null']); $resolver->setAllowedTypes('info_provider_dto', [PartDetailDTO::class, 'null']);

View file

@ -59,6 +59,10 @@ class UserAdminForm extends AbstractType
$resolver->setDefault('parameter_class', false); $resolver->setDefault('parameter_class', false);
$resolver->setDefault('validation_groups', ['Default', 'permissions:edit']); $resolver->setDefault('validation_groups', ['Default', 'permissions:edit']);
$resolver->setDefaults([
'warn_on_unsaved_changes' => true,
]);
} }
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void

View file

@ -1035,6 +1035,18 @@ Sub elements will be moved upwards.</target>
<target>Edit [part]</target> <target>Edit [part]</target>
</segment> </segment>
</unit> </unit>
<unit id="aznOqRU" name="form.dirty_form.unsaved_changes.title">
<segment state="translated">
<source>form.dirty_form.unsaved_changes.title</source>
<target>Unsaved Changes</target>
</segment>
</unit>
<unit id="dfzAYsv" name="form.dirty_form.unsaved_changes.message">
<segment state="translated">
<source>form.dirty_form.unsaved_changes.message</source>
<target>You have unsaved changes that will be lost if you leave this page. Are you sure you want to continue?</target>
</segment>
</unit>
<unit id="EwY218_" name="part.edit.tab.common"> <unit id="EwY218_" name="part.edit.tab.common">
<segment state="translated"> <segment state="translated">
<source>part.edit.tab.common</source> <source>part.edit.tab.common</source>