mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-27 13:51:38 +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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -25,9 +25,11 @@ import TomSelect from "tom-select";
|
|||
|
||||
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_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||
|
||||
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||
|
||||
export default class extends Controller {
|
||||
_tomSelect;
|
||||
|
|
@ -82,7 +84,8 @@ export default class extends Controller {
|
|||
'autoselect_typed': {},
|
||||
'click_to_edit': {},
|
||||
'clear_button': {},
|
||||
"restore_on_backspace": {}
|
||||
'restore_on_backspace': {},
|
||||
'form_reset_handler': {}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ import TomSelect from "tom-select";
|
|||
|
||||
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_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||
|
||||
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||
|
||||
export default class extends Controller {
|
||||
_tomSelect;
|
||||
|
|
@ -64,7 +66,8 @@ export default class extends Controller {
|
|||
'autoselect_typed': {},
|
||||
'click_to_edit': {},
|
||||
'clear_button': {},
|
||||
"restore_on_backspace": {}
|
||||
'restore_on_backspace': {},
|
||||
'form_reset_handler': {}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -91,6 +91,9 @@ export default class extends Controller {
|
|||
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();
|
||||
watchdog.setCreator((elementOrData, editorConfig) => {
|
||||
return EDITOR_TYPE.create(elementOrData, editorConfig)
|
||||
|
|
@ -111,10 +114,21 @@ export default class extends Controller {
|
|||
editor.updateSourceElement();
|
||||
|
||||
// Dispatch the input event for further treatment
|
||||
const event = new Event("input");
|
||||
this.element.dispatchEvent(event);
|
||||
this.element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
//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
|
||||
//See: https://github.com/ckeditor/ckeditor5/issues/5897#issuecomment-628471302
|
||||
return editor;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import "tom-select/dist/css/tom-select.bootstrap5.css";
|
|||
import '../../css/components/tom-select_extensions.css';
|
||||
import TomSelect from "tom-select";
|
||||
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 {
|
||||
_tomSelect;
|
||||
|
|
@ -18,7 +21,7 @@ export default class extends Controller {
|
|||
|
||||
let settings = {
|
||||
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"],
|
||||
valueField: "id",
|
||||
labelField: "name",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ import {Controller} from "@hotwired/stimulus";
|
|||
import "tom-select/dist/css/tom-select.bootstrap5.css";
|
||||
import '../../css/components/tom-select_extensions.css';
|
||||
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 {
|
||||
|
||||
|
|
@ -44,7 +47,7 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
let settings = {
|
||||
plugins: ["clear_button"],
|
||||
plugins: ["clear_button", "form_reset_handler"],
|
||||
allowEmptyOption: true,
|
||||
selectOnTab: true,
|
||||
maxOptions: null,
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ import TomSelect from "tom-select";
|
|||
|
||||
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_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||
|
||||
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||
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.
|
||||
|
|
@ -64,7 +66,8 @@ export default class extends Controller {
|
|||
'autoselect_typed': {},
|
||||
'click_to_edit': {},
|
||||
'clear_button': {},
|
||||
'restore_on_backspace': {}
|
||||
'restore_on_backspace': {},
|
||||
'form_reset_handler': {}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -23,9 +23,11 @@ import TomSelect from "tom-select";
|
|||
import {Controller} from "@hotwired/stimulus";
|
||||
|
||||
import {trans} from '../../translator.js'
|
||||
|
||||
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('form_reset_handler', TomSelect_form_reset_handler)
|
||||
|
||||
export default class extends Controller {
|
||||
_tomSelect;
|
||||
|
|
@ -96,6 +98,7 @@ export default class extends Controller {
|
|||
|
||||
plugins: {
|
||||
"autoselect_typed": {},
|
||||
"form_reset_handler": {},
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -105,6 +108,7 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
this._tomSelect = new TomSelect(this.element, settings);
|
||||
|
||||
//Do not do a sync here as this breaks the initial rendering of the empty option
|
||||
//this._tomSelect.sync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ import TomSelect from "tom-select";
|
|||
|
||||
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_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||
|
||||
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||
|
||||
export default class extends Controller {
|
||||
_tomSelect;
|
||||
|
|
@ -43,6 +45,7 @@ export default class extends Controller {
|
|||
remove_button:{},
|
||||
'autoselect_typed': {},
|
||||
'click_to_edit': {},
|
||||
'form_reset_handler': {},
|
||||
},
|
||||
persistent: false,
|
||||
selectOnTab: true,
|
||||
|
|
|
|||
|
|
@ -102,7 +102,18 @@ export default class extends Controller {
|
|||
onNodeSelected: (event) => {
|
||||
const node = event.detail.node;
|
||||
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]);
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ import "katex/dist/katex.css";
|
|||
|
||||
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_form_reset_handler from '../../tomselect/form_reset_handler/form_reset_handler'
|
||||
|
||||
TomSelect.define('click_to_edit', TomSelect_click_to_edit)
|
||||
TomSelect.define('autoselect_typed', TomSelect_autoselect_typed)
|
||||
TomSelect.define('form_reset_handler', TomSelect_form_reset_handler)
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default class extends Controller
|
||||
|
|
@ -63,7 +65,8 @@ export default class extends Controller
|
|||
'autoselect_typed': {},
|
||||
'click_to_edit': {},
|
||||
'clear_button': {},
|
||||
'restore_on_backspace': {}
|
||||
'restore_on_backspace': {},
|
||||
'form_reset_handler': {}
|
||||
},
|
||||
persistent: false,
|
||||
maxItems: 1,
|
||||
|
|
|
|||
61
assets/css/components/dirty_form.css
Normal file
61
assets/css/components/dirty_form.css
Normal 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;
|
||||
}
|
||||
|
|
@ -83,6 +83,8 @@ export default class TristateCheckbox {
|
|||
|
||||
//Do a refresh to set the correct styling of the checkbox
|
||||
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));
|
||||
}
|
||||
|
|
@ -202,6 +204,9 @@ export default class TristateCheckbox {
|
|||
}
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -56,6 +56,7 @@ class TristateHelper {
|
|||
|
||||
document.addEventListener("turbo:load", listener);
|
||||
document.addEventListener("turbo:render", listener);
|
||||
document.addEventListener("turbo:frame-load", listener);
|
||||
document.addEventListener("collection:elementAdded", listener);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
assets/tomselect/form_reset_handler/form_reset_handler.js
Normal file
46
assets/tomselect/form_reset_handler/form_reset_handler.js
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Component\Form\Extension\Core\Type\ResetType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use App\Settings\AppSettings;
|
||||
use Jbtronics\SettingsBundle\Form\SettingsFormFactoryInterface;
|
||||
|
|
@ -50,7 +51,9 @@ class SettingsController extends AbstractController
|
|||
$settings = $this->settingsManager->createTemporaryCopy(AppSettings::class);
|
||||
|
||||
//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
|
||||
$builder->add('submit', SubmitType::class, ['label' => 'save']);
|
||||
|
|
|
|||
|
|
@ -57,6 +57,10 @@ class BaseEntityAdminForm extends AbstractType
|
|||
$resolver->setRequired('attachment_class');
|
||||
$resolver->setRequired('parameter_class');
|
||||
$resolver->setAllowedTypes('parameter_class', ['string', 'null']);
|
||||
|
||||
$resolver->setDefaults([
|
||||
'warn_on_unsaved_changes' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
|
|
|
|||
84
src/Form/Extension/UnsavedChangesExtension.php
Normal file
84
src/Form/Extension/UnsavedChangesExtension.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -353,6 +353,7 @@ class PartBaseType extends AbstractType
|
|||
$resolver->setDefaults([
|
||||
'data_class' => Part::class,
|
||||
'info_provider_dto' => null,
|
||||
'warn_on_unsaved_changes' => true,
|
||||
]);
|
||||
|
||||
$resolver->setAllowedTypes('info_provider_dto', [PartDetailDTO::class, 'null']);
|
||||
|
|
|
|||
|
|
@ -59,6 +59,10 @@ class UserAdminForm extends AbstractType
|
|||
$resolver->setDefault('parameter_class', false);
|
||||
|
||||
$resolver->setDefault('validation_groups', ['Default', 'permissions:edit']);
|
||||
|
||||
$resolver->setDefaults([
|
||||
'warn_on_unsaved_changes' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
|
|
|
|||
|
|
@ -1035,6 +1035,18 @@ Sub elements will be moved upwards.</target>
|
|||
<target>Edit [part]</target>
|
||||
</segment>
|
||||
</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">
|
||||
<segment state="translated">
|
||||
<source>part.edit.tab.common</source>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue