mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-05-27 22:01:39 +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_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': {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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': {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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': {}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
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
|
//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 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
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']);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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([
|
$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']);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue