mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-07-01 06:51:39 +00:00
Compare commits
8 commits
ad0c60f766
...
e8af0e9b4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8af0e9b4f | ||
|
|
82cd5875fc | ||
|
|
2e223f4ee4 | ||
|
|
0da5befd7b | ||
|
|
87874230ef | ||
|
|
cf5a9e5667 | ||
|
|
f15e0ced2a | ||
|
|
79c36494ea |
32 changed files with 1703 additions and 953 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
2.11.1
|
||||
2.12.0
|
||||
|
|
|
|||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
590
composer.lock
generated
590
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -63,8 +63,8 @@
|
|||
"exports-loader": "^5.0.0",
|
||||
"json-formatter-js": "^2.3.4",
|
||||
"jszip": "^3.2.0",
|
||||
"katex": "^0.16.0",
|
||||
"marked": "^17.0.1",
|
||||
"katex": "^0.17.0",
|
||||
"marked": "^18.0.0",
|
||||
"marked-gfm-heading-id": "^4.1.1",
|
||||
"marked-mangle": "^1.0.1",
|
||||
"pdfmake": "^0.3.7",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated on Mon May 4 05:40:05 UTC 2026
|
||||
# Generated on Mon May 25 06:41:46 UTC 2026
|
||||
# This file contains all footprints available in the offical KiCAD library
|
||||
Audio_Module:Reverb_BTDR-1H
|
||||
Audio_Module:Reverb_BTDR-1V
|
||||
|
|
@ -11883,6 +11883,8 @@ Package_DFN_QFN:Texas_VQFN-HR-20_3x2.5mm_P0.5mm_RQQ0011A
|
|||
Package_DFN_QFN:Texas_VQFN-RHL-20
|
||||
Package_DFN_QFN:Texas_VQFN-RHL-20_ThermalVias
|
||||
Package_DFN_QFN:Texas_VQFN-RNR0011A-11
|
||||
Package_DFN_QFN:Texas_WQFN-40-1EP_3x6mm_P0.4mm_EP1.7x4.5mm
|
||||
Package_DFN_QFN:Texas_WQFN-40-1EP_3x6mm_P0.4mm_EP1.7x4.5mm_ThermalVias
|
||||
Package_DFN_QFN:Texas_WQFN-MR-100_3x3-DapStencil
|
||||
Package_DFN_QFN:Texas_WQFN-MR-100_ThermalVias_3x3-DapStencil
|
||||
Package_DFN_QFN:Texas_X2QFN-12_1.6x1.6mm_P0.4mm
|
||||
|
|
@ -12956,6 +12958,7 @@ Package_SON:MPS_VSON-6_1x1.5mm_P0.5mm
|
|||
Package_SON:MicroCrystal_C7_SON-8_1.5x3.2mm_P0.9mm
|
||||
Package_SON:Microchip_USON-10-1EP_3x3mm_P0.5mm_EP1.8x2.5mm
|
||||
Package_SON:Microchip_USON-10-1EP_3x3mm_P0.5mm_EP1.8x2.5mm_ThermalVias
|
||||
Package_SON:NXP_LSON-16-1EP_3.5x4.5mm_P0.5mm_EP2x3.8mm
|
||||
Package_SON:NXP_XSON-16
|
||||
Package_SON:Nexperia_HUSON-12_USON-12-1EP_1.35x2.5mm_P0.4mm_EP0.4x2mm
|
||||
Package_SON:Nexperia_HUSON-16_USON-16-1EP_1.35x3.3mm_P0.4mm_EP0.4x2.8mm
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated on Mon May 4 05:40:43 UTC 2026
|
||||
# Generated on Mon May 25 06:42:27 UTC 2026
|
||||
# This file contains all symbols available in the offical KiCAD library
|
||||
4xxx:14528
|
||||
4xxx:14529
|
||||
|
|
@ -7545,6 +7545,7 @@ Driver_FET:ZXGD3003E6
|
|||
Driver_FET:ZXGD3004E6
|
||||
Driver_FET:ZXGD3006E6
|
||||
Driver_FET:ZXGD3009E6
|
||||
Driver_LED:AL5819W6
|
||||
Driver_LED:AL8860MP
|
||||
Driver_LED:AL8860WT
|
||||
Driver_LED:AP3019AKTR
|
||||
|
|
@ -7692,6 +7693,7 @@ Driver_Motor:DRV8311P
|
|||
Driver_Motor:DRV8311S
|
||||
Driver_Motor:DRV8412
|
||||
Driver_Motor:DRV8432
|
||||
Driver_Motor:DRV8434PWP
|
||||
Driver_Motor:DRV8461SPWP
|
||||
Driver_Motor:DRV8662
|
||||
Driver_Motor:DRV8800PWP
|
||||
|
|
@ -19322,6 +19324,9 @@ Regulator_Switching:MAX15062C
|
|||
Regulator_Switching:MAX1522
|
||||
Regulator_Switching:MAX1523
|
||||
Regulator_Switching:MAX1524
|
||||
Regulator_Switching:MAX15462A
|
||||
Regulator_Switching:MAX15462B
|
||||
Regulator_Switching:MAX15462C
|
||||
Regulator_Switching:MAX17501AxTB
|
||||
Regulator_Switching:MAX17501BxTB
|
||||
Regulator_Switching:MAX17501ExTB
|
||||
|
|
@ -21301,6 +21306,7 @@ Timer_RTC:MCP79512-xMS
|
|||
Timer_RTC:MCP79520-xMS
|
||||
Timer_RTC:MCP79521-xMS
|
||||
Timer_RTC:MCP79522-xMS
|
||||
Timer_RTC:PCA2131
|
||||
Timer_RTC:PCF85063ATL
|
||||
Timer_RTC:PCF8523T
|
||||
Timer_RTC:PCF8523TK
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,9 +23,13 @@ declare(strict_types=1);
|
|||
|
||||
namespace App\Serializer\APIPlatform;
|
||||
|
||||
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
|
||||
use ApiPlatform\Metadata\Exception\ItemNotFoundException;
|
||||
use ApiPlatform\Metadata\IriConverterInterface;
|
||||
use ApiPlatform\Serializer\ItemNormalizer;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface;
|
||||
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
|
||||
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
|
||||
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
|
||||
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
|
||||
use Symfony\Component\Serializer\SerializerAwareInterface;
|
||||
|
|
@ -35,6 +39,10 @@ use Symfony\Component\Serializer\SerializerInterface;
|
|||
* This class decorates API Platform's ItemNormalizer to allow skipping the normalization process by setting the
|
||||
* DISABLE_ITEM_NORMALIZER context key to true. This is useful for all kind of serialization operations, where the API
|
||||
* Platform subsystem should not be used.
|
||||
*
|
||||
* It also works around a bug in API Platform's AbstractItemNormalizer where IRI strings for abstract resource classes
|
||||
* with a discriminator map fail deserialization when objectToPopulate is null (the discriminator is checked before
|
||||
* the IRI string check). See: https://github.com/Part-DB/Part-DB-server/issues/1370
|
||||
*/
|
||||
#[AsDecorator("api_platform.serializer.normalizer.item")]
|
||||
class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface
|
||||
|
|
@ -42,13 +50,44 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf
|
|||
|
||||
public const DISABLE_ITEM_NORMALIZER = 'DISABLE_ITEM_NORMALIZER';
|
||||
|
||||
public function __construct(private readonly ItemNormalizer $inner)
|
||||
{
|
||||
|
||||
public function __construct(
|
||||
private readonly ItemNormalizer $inner,
|
||||
private readonly IriConverterInterface $iriConverter,
|
||||
) {
|
||||
}
|
||||
|
||||
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
|
||||
{
|
||||
// API Platform's AbstractItemNormalizer has a bug: when objectToPopulate is null and data is an IRI
|
||||
// string, it tries to resolve the discriminator class from [$iri_string] before reaching the IRI
|
||||
// check (line 271). For abstract resource classes with a discriminator map (e.g. Attachment), this
|
||||
// fails because the array has no _type key. Fix by resolving IRI strings directly.
|
||||
// See: https://github.com/Part-DB/Part-DB-server/issues/1370
|
||||
if (is_string($data) || (is_array($data) && isset($data['@id']) && is_string($data['@id']))) {
|
||||
if (is_array($data)) {
|
||||
$iri = $data['@id'];
|
||||
} else {
|
||||
$iri = $data;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->iriConverter->getResourceFromIri($iri, $context + ['fetch_data' => true]);
|
||||
} catch (ItemNotFoundException $e) {
|
||||
if (false === ($context['denormalize_throw_on_relation_not_found'] ?? true)) {
|
||||
return null;
|
||||
}
|
||||
if (!isset($context['not_normalizable_value_exceptions'])) {
|
||||
throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [$type], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
if (!isset($context['not_normalizable_value_exceptions'])) {
|
||||
throw new UnexpectedValueException(sprintf('Invalid IRI "%s".', $iri), $e->getCode(), $e);
|
||||
}
|
||||
throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Invalid IRI "%s".', $data), $data, [$type], $context['deserialization_path'] ?? null, true, $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->inner->denormalize($data, $type, $format, $context);
|
||||
}
|
||||
|
||||
|
|
@ -87,4 +126,4 @@ class SkippableItemNormalizer implements NormalizerInterface, DenormalizerInterf
|
|||
'object' => false
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,4 +69,52 @@ final class PartEndpointTest extends CrudEndpointTestCase
|
|||
{
|
||||
$this->_testDeleteItem(1);
|
||||
}
|
||||
}
|
||||
|
||||
public function testMasterPictureAttachmentPatchWithIRI(): void
|
||||
{
|
||||
$client = static::createAuthenticatedClient();
|
||||
|
||||
// Create a new attachment with a picture URL for Part 1
|
||||
$response = $client->request('POST', '/api/attachments', ['json' => [
|
||||
'name' => 'Test Picture',
|
||||
'url' => 'http://example.com/test.jpg',
|
||||
'_type' => 'Part',
|
||||
'element' => '/api/parts/1',
|
||||
'attachment_type' => '/api/attachment_types/1',
|
||||
]]);
|
||||
self::assertResponseIsSuccessful();
|
||||
$attachmentIri = $response->toArray()['@id'];
|
||||
|
||||
// Now PATCH Part 1 to set master_picture_attachment
|
||||
$client->request('PATCH', '/api/parts/1', [
|
||||
'json' => ['master_picture_attachment' => $attachmentIri],
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
]);
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertJsonContains(['master_picture_attachment' => ['@id' => $attachmentIri]]);
|
||||
}
|
||||
|
||||
public function testMasterPictureAttachmentPatchWithArray(): void
|
||||
{
|
||||
$client = static::createAuthenticatedClient();
|
||||
|
||||
// Create a new attachment with a picture URL for Part 1
|
||||
$response = $client->request('POST', '/api/attachments', ['json' => [
|
||||
'name' => 'Test Picture',
|
||||
'url' => 'http://example.com/test.jpg',
|
||||
'_type' => 'Part',
|
||||
'element' => '/api/parts/1',
|
||||
'attachment_type' => '/api/attachment_types/1',
|
||||
]]);
|
||||
self::assertResponseIsSuccessful();
|
||||
$attachmentIri = $response->toArray()['@id'];
|
||||
|
||||
// Now PATCH Part 1 to set master_picture_attachment
|
||||
$client->request('PATCH', '/api/parts/1', [
|
||||
'json' => ['master_picture_attachment' => ['@id' => $attachmentIri, '_type' => 'Part']],
|
||||
'headers' => ['Content-Type' => 'application/merge-patch+json'],
|
||||
]);
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertJsonContains(['master_picture_attachment' => ['@id' => $attachmentIri]]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,59 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<xliff xmlns="urn:oasis:names:tc:xliff:document:2.0" version="2.0" srcLang="en" trgLang="nl">
|
||||
<file id="frontend.nl">
|
||||
<unit id="lQ8QeGr" name="search.placeholder">
|
||||
<segment state="translated">
|
||||
<source>search.placeholder</source>
|
||||
<target>Zoeken</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<file id="frontend.en">
|
||||
<unit id="eLrezdb" name="search.placeholder">
|
||||
<segment state="translated">
|
||||
<source>search.placeholder</source>
|
||||
<target>Zoeken</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="R4hoCqe" name="part.labelp">
|
||||
<segment state="translated">
|
||||
<source>part.labelp</source>
|
||||
<target>Onderdelen</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="S4CxO.T" name="entity.select.group.new_not_added_to_DB">
|
||||
<segment state="translated">
|
||||
<source>entity.select.group.new_not_added_to_DB</source>
|
||||
<target>Nieuw (nog niet toegevoegd aan de database)</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="9rnHbSK" name="user.password_strength.very_weak">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.very_weak</source>
|
||||
<target>Zeer zwak</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="gKHmHwM" name="user.password_strength.weak">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.weak</source>
|
||||
<target>Zwak</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="c44gN8b" name="user.password_strength.medium">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.medium</source>
|
||||
<target>Gemiddeld</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="NwiBLHc" name="user.password_strength.strong">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.strong</source>
|
||||
<target>Sterk</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="Bw.iCUm" name="user.password_strength.very_strong">
|
||||
<segment state="translated">
|
||||
<source>user.password_strength.very_strong</source>
|
||||
<target>Zeer sterk</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="U5IhkwB" name="search.submit">
|
||||
<segment state="translated">
|
||||
<source>search.submit</source>
|
||||
<target>Ga!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
|
|
|||
|
|
@ -1034,6 +1034,18 @@ Subelemente werden beim Löschen nach oben verschoben.</target>
|
|||
<target>Bearbeite Bauteileinformationen von</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>Ungespeicherte Änderungen</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>Sie haben ungespeicherte Änderungen vorgenommen, die verloren gehen, wenn Sie diese Seite verlassen. Möchten Sie wirklich fortfahren?</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="EwY218_" name="part.edit.tab.common">
|
||||
<segment state="translated">
|
||||
<source>part.edit.tab.common</source>
|
||||
|
|
@ -13605,5 +13617,35 @@ Buerklin-API-Authentication-Server:
|
|||
<target>Host-URL</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="kuDv.So" name="browser_plugin.recent_pages.title">
|
||||
<segment state="translated">
|
||||
<source>browser_plugin.recent_pages.title</source>
|
||||
<target>Zuletzt besuchte Seiten</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="AjNj8wk" name="browser_plugin.recent_pages.help">
|
||||
<segment state="translated">
|
||||
<source>browser_plugin.recent_pages.help</source>
|
||||
<target>Hier werden die zuletzt besuchten Seiten angezeigt.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="lVUU9s7" name="settings.ips.browser_plugin">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.browser_plugin</source>
|
||||
<target>Browser-Plugin</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="IrJs3fI" name="settings.ips.browser_plugin.description">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.browser_plugin.description</source>
|
||||
<target>Einstellungen für das Browser-Plugin</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="_8UrMCB" name="settings.ips.browser_plugin.enabled.help">
|
||||
<segment state="translated">
|
||||
<source>settings.ips.browser_plugin.enabled.help</source>
|
||||
<target>Aktiviert das Browser-Plugin für diese Instanz.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -13620,19 +13632,19 @@ Buerklin-API Authentication server:
|
|||
</segment>
|
||||
</unit>
|
||||
<unit id="lVUU9s7" name="settings.ips.browser_plugin">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>settings.ips.browser_plugin</source>
|
||||
<target>Browser plugin</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="IrJs3fI" name="settings.ips.browser_plugin.description">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>settings.ips.browser_plugin.description</source>
|
||||
<target>The browser plugin allows to submit pages to Part-DB directly from a browser to create new parts. HTML content is submitted, so that extraction even works on DDOS protected pages, or pages requiring javascript for correct rendering. The Generic Web or AI Web extractor needs to be enabled to be useful.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="_8UrMCB" name="settings.ips.browser_plugin.enabled.help">
|
||||
<segment>
|
||||
<segment state="translated">
|
||||
<source>settings.ips.browser_plugin.enabled.help</source>
|
||||
<target>When enabled users with the info provider permission can submit pages to Part-DB and retrieve them later.</target>
|
||||
</segment>
|
||||
|
|
|
|||
|
|
@ -223,13 +223,13 @@
|
|||
<target>Vanwege technische beperkingen is het niet mogelijk om datums na 2038-01-19 te selecteren op 32-bits systemen!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="89nojXY" name="validator.fileSize.invalidFormat">
|
||||
<unit id="iM9yb_p" name="validator.fileSize.invalidFormat">
|
||||
<segment state="translated">
|
||||
<source>validator.fileSize.invalidFormat</source>
|
||||
<target>Ongeldig bestandsformaat. Gebruiker een geheel getal plus K, M of G als toevoeging voor Kilo, Mega of Gigabytes.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="iXcU7ce" name="validator.invalid_range">
|
||||
<unit id="ZFxQ0BZ" name="validator.invalid_range">
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_range</source>
|
||||
<target>De opgegeven reeks is niet geldig!</target>
|
||||
|
|
@ -241,5 +241,17 @@
|
|||
<target>Ongeldige code. Controleer of je authenticator-app correct is ingesteld en of zowel de server als het authenticatieapparaat de tijd correct hebben ingesteld.</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="I330cr5" name="settings.synonyms.type_synonyms.collection_type.duplicate">
|
||||
<segment state="translated">
|
||||
<source>settings.synonyms.type_synonyms.collection_type.duplicate</source>
|
||||
<target>Er is al een vertaling gedefinieerd voor dit type en deze taal!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
<unit id="zT_j_oQ" name="validator.invalid_gtin">
|
||||
<segment state="translated">
|
||||
<source>validator.invalid_gtin</source>
|
||||
<target>Dit is geen geldige GTIN / EAN!</target>
|
||||
</segment>
|
||||
</unit>
|
||||
</file>
|
||||
</xliff>
|
||||
</xliff>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue