diff --git a/assets/controllers/elements/collection_type_controller.js b/assets/controllers/elements/collection_type_controller.js index caeb41229..e6decbdf8 100644 --- a/assets/controllers/elements/collection_type_controller.js +++ b/assets/controllers/elements/collection_type_controller.js @@ -32,6 +32,40 @@ export default class extends Controller { static targets = ["target"]; + connect() { + // Native form reset only restores controls that still exist in the DOM. Keep the initial collection structure + // so persisted rows removed by the user can be recreated and rows added from the prototype can be discarded. + this._initialTarget = this.targetTarget.cloneNode(true); + this._form = this.element.closest('form'); + if (this._form) { + this._resetHandler = this.onFormReset.bind(this); + this._form.addEventListener('reset', this._resetHandler); + } + } + + disconnect() { + clearTimeout(this._resetTimer); + if (this._form && this._resetHandler) { + this._form.removeEventListener('reset', this._resetHandler); + } + } + + onFormReset() { + clearTimeout(this._resetTimer); + // Wait until the browser has completed its native value reset before rebuilding the collection structure. + this._resetTimer = setTimeout(() => this.restoreInitialStructure(), 0); + } + + restoreInitialStructure() { + if (!this._initialTarget || !this.element.isConnected) { + return; + } + + const restoredTarget = this._initialTarget.cloneNode(true); + this.targetTarget.replaceChildren(...restoredTarget.childNodes); + this.targetTarget.dispatchEvent(new CustomEvent("collection:reset", {bubbles: true})); + } + /** * Decodes escaped HTML entities * @param {string} input diff --git a/assets/controllers/filters/parameter_constraint_controller.js b/assets/controllers/filters/parameter_constraint_controller.js new file mode 100644 index 000000000..f394e6f18 --- /dev/null +++ b/assets/controllers/filters/parameter_constraint_controller.js @@ -0,0 +1,342 @@ +/* + * 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. + */ + +import {Controller} from '@hotwired/stimulus'; +import TomSelect from 'tom-select'; +import {trans} from '../../translator'; +import 'tom-select/dist/css/tom-select.bootstrap5.css'; +import '../../css/components/tom-select_extensions.css'; + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + static values = { + url: String, + initialInputType: String, + initialChoices: Array, + initialDeprecatedChoices: Array, + deprecatedChoiceLabel: String, + }; + + static targets = [ + 'name', + 'definition', + 'symbol', + 'symbolContainer', + 'unit', + 'unitContainer', + 'numericContainer', + 'valueOperator', + 'valueText', + ]; + + connect() { + this._initialized = false; + this._restoring = false; + this._initialState = this.captureState(); + this._form = this.nameTarget.form; + this._resetHandler = this.onFormReset.bind(this); + this._form?.addEventListener('reset', this._resetHandler); + + this.setupNameTomSelect(); + + const linked = this.definitionTarget.value !== ''; + this.setAdHocFieldState(linked, linked); + this.configureCurrentValueEditor(linked ? this.initialInputTypeValue : 'text'); + } + + disconnect() { + clearTimeout(this._resetTimer); + this._form?.removeEventListener('reset', this._resetHandler); + this._nameTomSelect?.destroy(); + this.destroyValueTomSelect(); + } + + setupNameTomSelect() { + const settings = { + plugins: ['clear_button', 'restore_on_backspace'], + persistent: false, + maxItems: 1, + delimiter: 'VERY_L0NG_D€LIMITER_WHICH_WILL_NEVER_BE_ENCOUNTERED_IN_A_STRING', + createOnBlur: true, + selectOnTab: true, + create: true, + searchField: 'name', + valueField: 'name', + labelField: 'name', + clearAfterSelect: true, + onItemAdd: value => this.onNameItemAdd(value), + onItemRemove: () => this.onNameItemRemove(), + onInitialize: () => { + this._initialized = true; + }, + render: { + option: (data, escape) => { + let details = ''; + if (data.symbol) { + details += escape(data.symbol); + } + if (data.unit) { + details += (details === '' ? '' : ' ') + '[' + escape(data.unit) + ']'; + } + + return '
' + escape(data.name) + '' + + (details === '' ? '' : '
' + details + '') + + '
'; + }, + item: (data, escape) => '
' + escape(data.name) + '
', + }, + }; + + if (this.hasUrlValue) { + const baseUrl = this.urlValue; + settings.load = (query, callback) => { + fetch(baseUrl.replace('__QUERY__', encodeURIComponent(query))) + .then(response => response.json()) + .then(data => callback(data)) + .catch(() => callback()); + }; + } + + this._nameTomSelect = new TomSelect(this.nameTarget, settings); + } + + onNameItemAdd(value) { + if (!this._initialized || this._restoring) { + return; + } + + const suggestion = this._nameTomSelect.options[value] ?? {}; + const definitionId = Number(suggestion.definition_id); + if (Number.isInteger(definitionId) && definitionId > 0) { + this.enterDefinitionMode({ + id: definitionId, + name: suggestion.name ?? value, + inputType: suggestion.input_type === 'choice' ? 'choice' : 'text', + choices: Array.isArray(suggestion.choices) ? suggestion.choices : [], + deprecatedChoices: Array.isArray(suggestion.deprecated_choices) + ? suggestion.deprecated_choices + : [], + }); + return; + } + + this.enterAdHocMode(suggestion); + } + + onNameItemRemove() { + if (!this._initialized || this._restoring) { + return; + } + + this.enterAdHocMode(); + } + + enterDefinitionMode(definition) { + this.setDefinition(definition.id, definition.name); + this.setAdHocFieldState(true, true); + this.replaceValueEditor( + definition.inputType, + definition.choices, + definition.deprecatedChoices, + '', + '', + ); + } + + enterAdHocMode(suggestion = {}) { + this.setDefinition(null); + this.setAdHocFieldState(false, true); + this.replaceValueEditor('text', [], [], '', ''); + + this.symbolTarget.value = suggestion.symbol ?? ''; + this.unitTarget.value = suggestion.unit ?? ''; + this.symbolTarget.dispatchEvent(new Event('input', {bubbles: true})); + this.unitTarget.dispatchEvent(new Event('input', {bubbles: true})); + } + + setDefinition(id, name = '') { + if (id === null) { + this.definitionTarget.value = ''; + } else { + const value = String(id); + if (!Array.from(this.definitionTarget.options).some(option => option.value === value)) { + this.definitionTarget.add(new Option(name, value)); + } + this.definitionTarget.value = value; + } + + this.definitionTarget.dispatchEvent(new Event('change', {bubbles: true})); + } + + setAdHocFieldState(linked, clear) { + for (const container of [this.symbolContainerTarget, this.numericContainerTarget, this.unitContainerTarget]) { + container.classList.toggle('d-none', linked); + } + + const fields = [ + this.symbolTarget, + ...this.numericContainerTarget.querySelectorAll('input, select'), + this.unitTarget, + ]; + + for (const field of fields) { + if (clear) { + field.value = ''; + if (field.tomselect) { + field.tomselect.clear(true); + field.tomselect.sync(); + } + } + + field.disabled = linked; + if (field.tomselect) { + if (linked) { + field.tomselect.disable(); + } else { + field.tomselect.enable(); + } + } + } + + if (clear) { + this.symbolTarget.dispatchEvent(new Event('input', {bubbles: true})); + this.unitTarget.dispatchEvent(new Event('input', {bubbles: true})); + } + } + + configureCurrentValueEditor(inputType) { + const choiceMode = inputType === 'choice' && this.valueTextTarget.tagName === 'SELECT'; + this.valueOperatorTarget.classList.toggle('d-none', choiceMode); + this.valueOperatorTarget.value = choiceMode ? '=' : this.valueOperatorTarget.value; + + if (choiceMode) { + this.setupValueTomSelect(this.valueTextTarget); + } + } + + replaceValueEditor(inputType, choices, deprecatedChoices, value, operator) { + this.destroyValueTomSelect(); + const oldElement = this.valueTextTarget; + const choiceMode = inputType === 'choice'; + const newElement = document.createElement(choiceMode ? 'select' : 'input'); + + for (const attribute of oldElement.attributes) { + if (attribute.name !== 'type' && attribute.name !== 'class') { + newElement.setAttribute(attribute.name, attribute.value); + } + } + newElement.setAttribute('data-controller', ''); + + if (choiceMode) { + newElement.className = 'form-select'; + newElement.add(new Option('', '')); + for (const choice of choices) { + newElement.add(new Option(choice, choice)); + } + for (const choice of deprecatedChoices) { + newElement.add(new Option( + this.deprecatedChoiceLabelValue.replace('__PARAMETER_CHOICE__', () => choice), + choice, + )); + } + newElement.value = [...choices, ...deprecatedChoices].includes(value) ? value : ''; + } else { + newElement.type = 'search'; + newElement.className = 'form-control'; + newElement.value = value; + } + + oldElement.replaceWith(newElement); + this.valueOperatorTarget.classList.toggle('d-none', choiceMode); + this.valueOperatorTarget.value = choiceMode ? '=' : operator; + + if (choiceMode) { + this.setupValueTomSelect(newElement); + } + } + + setupValueTomSelect(element) { + if (element.tomselect) { + element.tomselect.destroy(); + } + + this._valueTomSelect = new TomSelect(element, { + plugins: ['clear_button'], + allowEmptyOption: true, + create: false, + maxItems: 1, + selectOnTab: true, + placeholder: trans('parameter.choice.nothing_selected'), + }); + } + + destroyValueTomSelect() { + this._valueTomSelect?.destroy(); + this._valueTomSelect = undefined; + } + + captureState() { + return { + name: this.nameTarget.value, + definitionId: this.definitionTarget.value, + definitionName: this.definitionTarget.selectedOptions[0]?.text ?? this.nameTarget.value, + inputType: this.initialInputTypeValue, + choices: [...this.initialChoicesValue], + deprecatedChoices: [...this.initialDeprecatedChoicesValue], + symbol: this.symbolTarget.value, + unit: this.unitTarget.value, + numeric: Array.from(this.numericContainerTarget.querySelectorAll('input, select')).map(field => field.value), + value: this.valueTextTarget.value, + operator: this.valueOperatorTarget.value, + }; + } + + onFormReset() { + clearTimeout(this._resetTimer); + this._resetTimer = setTimeout(() => this.restoreInitialState(), 0); + } + + restoreInitialState() { + if (!this.element.isConnected) { + return; + } + + const state = this._initialState; + this._restoring = true; + this._nameTomSelect.clear(true); + if (state.name !== '') { + if (!this._nameTomSelect.options[state.name]) { + this._nameTomSelect.addOption({name: state.name}); + } + this._nameTomSelect.setValue(state.name, true); + } + this.setDefinition(state.definitionId === '' ? null : state.definitionId, state.definitionName); + + this.symbolTarget.value = state.symbol; + this.unitTarget.value = state.unit; + const numericFields = this.numericContainerTarget.querySelectorAll('input, select'); + numericFields.forEach((field, index) => { + field.value = state.numeric[index] ?? ''; + field.tomselect?.sync(); + }); + + this.replaceValueEditor( + state.inputType, + state.choices, + state.deprecatedChoices, + state.value, + state.operator, + ); + const linked = state.definitionId !== ''; + this.setAdHocFieldState(linked, linked); + this._restoring = false; + } +} diff --git a/assets/controllers/pages/parameters_autocomplete_controller.js b/assets/controllers/pages/parameters_autocomplete_controller.js index 8a1e34f72..769d9054d 100644 --- a/assets/controllers/pages/parameters_autocomplete_controller.js +++ b/assets/controllers/pages/parameters_autocomplete_controller.js @@ -20,6 +20,7 @@ import {Controller} from "@hotwired/stimulus"; import TomSelect from "tom-select"; import katex from "katex"; +import {trans} from "../../translator"; import "katex/dist/katex.css"; @@ -38,9 +39,16 @@ export default class extends Controller url: String, } - static targets = ["name", "symbol", "unit"] + static targets = ["name", "symbol", "unit", "valueText", "definition", "newChoiceValue"] _tomSelect; + _valueTomSelect; + _initialized = false; + _resetting = false; + _initialState; + _form; + _resetHandler; + _resetTimer; onItemAdd(value, item) { //Retrieve the unit and symbol from the item @@ -57,9 +65,305 @@ export default class extends Controller //Trigger input event to update the preview this.unitTarget.dispatchEvent(new Event('input')); } + + // TomSelect emits onItemAdd for the value already present while initializing an existing row. The server has + // rendered that row from its persisted definition, so only an explicit user selection may change the link. + if (this._resetting || !this._initialized || !this.hasDefinitionTarget || !this.hasValueTextTarget) { + return; + } + + const definitionId = item.dataset.definitionId; + if (definitionId === undefined || !/^\d+$/.test(definitionId) || Number(definitionId) < 1) { + this.setDefinition(null); + this.applyInputDefinition('text', []); + this.setLinkedFieldState(false); + + return; + } + + let choices = []; + if (item.dataset.choices) { + try { + choices = JSON.parse(item.dataset.choices); + } catch (_) { + choices = []; + } + } + + this.setDefinition(definitionId, item.dataset.definitionName ?? value); + this.applyInputDefinition(item.dataset.inputType ?? 'text', choices); + this.setLinkedFieldState(true); + } + + onItemRemove() { + if (this._resetting || !this._initialized || !this.hasDefinitionTarget || !this.hasValueTextTarget) { + return; + } + + this.setDefinition(null); + this.applyInputDefinition('text', []); + this.setLinkedFieldState(false); + } + + setDefinition(definitionId, name = '') { + this.definitionTarget.replaceChildren(); + + const emptyOption = new Option('', ''); + this.definitionTarget.add(emptyOption); + + if (definitionId !== null) { + const option = new Option(name, definitionId, true, true); + this.definitionTarget.add(option); + this.definitionTarget.value = definitionId; + } else { + this.definitionTarget.value = ''; + } + + this.definitionTarget.dispatchEvent(new Event('change', {bubbles: true})); + } + + applyInputDefinition(inputType, choices, restoredValue = undefined, deprecatedChoices = []) { + this.destroyValueTomSelect(); + const oldElement = this.valueTextTarget; + const currentValue = restoredValue ?? oldElement.value; + const useChoice = inputType === 'choice' && Array.isArray(choices); + const newElement = document.createElement(useChoice ? 'select' : 'input'); + + for (const attribute of oldElement.attributes) { + if (attribute.name !== 'type') { + newElement.setAttribute(attribute.name, attribute.value); + } + } + + if (useChoice) { + newElement.classList.remove('form-control', 'form-control-sm'); + newElement.classList.add('form-select', 'form-select-sm'); + newElement.add(new Option('', '')); + + for (const choice of choices) { + newElement.add(new Option(choice, choice)); + } + + for (const choice of deprecatedChoices) { + const option = new Option( + trans('parameter_definition.choice.deprecated_label', {'%choice%': choice}), + choice, + ); + option.dataset.deprecatedChoice = 'true'; + newElement.add(option); + } + + newElement.value = [...choices, ...deprecatedChoices].includes(currentValue) ? currentValue : ''; + } else { + newElement.type = 'text'; + newElement.classList.remove('form-select', 'form-select-sm'); + newElement.classList.add('form-control', 'form-control-sm'); + newElement.value = currentValue; + } + + oldElement.replaceWith(newElement); + this.clearPendingChoice(); + if (useChoice) { + this.setupValueTomSelect(newElement); + } + newElement.dispatchEvent(new Event('change', {bubbles: true})); + } + + setLinkedFieldState(linked) { + if (this.hasSymbolTarget) { + this.symbolTarget.readOnly = linked; + } + if (this.hasUnitTarget) { + this.unitTarget.readOnly = linked; + } + } + + normalizeChoice(value) { + return value.trim().toLocaleLowerCase(); + } + + findCanonicalChoice(value) { + const normalized = this.normalizeChoice(value); + if (normalized === '' || !this._valueTomSelect) { + return null; + } + + for (const option of Object.values(this._valueTomSelect.options)) { + if (!option.pending_choice && this.normalizeChoice(String(option.value ?? '')) === normalized) { + return String(option.value); + } + } + + return null; + } + + clearPendingChoice() { + if (this.hasNewChoiceValueTarget) { + this.newChoiceValueTarget.value = ''; + } + } + + setupValueTomSelect(element) { + const canAddChoice = element.dataset.canAddChoice === 'true'; + const pendingChoice = this.hasNewChoiceValueTarget ? this.newChoiceValueTarget.value.trim() : ''; + const options = Array.from(element.options).map(option => ({ + value: option.value, + text: option.text, + pending_choice: pendingChoice !== '' && option.value === pendingChoice, + })); + + this._valueTomSelect = new TomSelect(element, { + plugins: { + 'clear_button': {}, + 'form_reset_handler': {}, + }, + options, + items: element.value === '' ? [] : [element.value], + valueField: 'value', + labelField: 'text', + searchField: 'text', + maxItems: 1, + allowEmptyOption: true, + placeholder: trans('parameter.choice.nothing_selected'), + createOnBlur: false, + selectOnTab: true, + createFilter: input => canAddChoice + && this.normalizeChoice(input) !== '' + && this.findCanonicalChoice(input) === null, + create: canAddChoice ? (input, callback) => { + const choice = input.trim(); + if (choice === '' || this.findCanonicalChoice(choice) !== null) { + callback(false); + return; + } + + callback({value: choice, text: choice, pending_choice: true}); + } : false, + onType: input => { + const canonical = this.findCanonicalChoice(input); + if (canonical !== null && canonical !== input) { + this._valueTomSelect.setTextboxValue(canonical); + this._valueTomSelect.refreshOptions(false); + } + }, + onItemAdd: value => { + // Initial items are added while the TomSelect constructor is still running, before the instance has + // been assigned to _valueTomSelect. + const option = this._valueTomSelect?.options[value] + ?? options.find(candidate => candidate.value === value); + if (this.hasNewChoiceValueTarget) { + this.newChoiceValueTarget.value = option?.pending_choice ? String(option.value) : ''; + } + }, + onItemRemove: () => this.clearPendingChoice(), + render: { + option_create: (data, escape) => '
' + + escape(trans('parameter.choice.add_new', {'%value%': data.input})) + + ' ' + escape(trans('parameter.choice.new')) + '
', + item: (data, escape) => '
' + escape(data.text) + + (data.pending_choice + ? ' ' + escape(trans('parameter.choice.new')) + '' + : '') + + '
', + }, + }); + } + + destroyValueTomSelect() { + this._valueTomSelect?.destroy(); + this._valueTomSelect = undefined; + } + + captureInitialState() { + const valueElement = this.valueTextTarget; + const isChoice = valueElement.tagName === 'SELECT'; + const definitionId = this.hasDefinitionTarget ? this.definitionTarget.value : ''; + const selectedDefinition = this.hasDefinitionTarget + ? this.definitionTarget.options[this.definitionTarget.selectedIndex] + : null; + + this._initialState = { + name: this.nameTarget.value, + symbol: this.hasSymbolTarget ? this.symbolTarget.value : '', + unit: this.hasUnitTarget ? this.unitTarget.value : '', + definitionId, + definitionName: selectedDefinition?.text ?? this.nameTarget.value, + inputType: isChoice ? 'choice' : 'text', + choices: isChoice + ? Array.from(valueElement.options) + .filter(option => option.value !== '' && option.dataset.deprecatedChoice !== 'true') + .map(option => option.value) + : [], + deprecatedChoices: isChoice + ? Array.from(valueElement.options) + .filter(option => option.value !== '' && option.dataset.deprecatedChoice === 'true') + .map(option => option.value) + : [], + value: valueElement.value, + }; + } + + onFormReset() { + this._resetting = true; + clearTimeout(this._resetTimer); + + // The browser restores native form controls after the reset event. Rebuild the composite TomSelect state on + // the next task, once both the native reset and the individual TomSelect reset handlers have completed. + this._resetTimer = setTimeout(() => this.restoreInitialState(), 0); + } + + restoreInitialState() { + if (!this._initialState || !this.element.isConnected) { + this._resetting = false; + return; + } + + const state = this._initialState; + this.clearPendingChoice(); + + if (state.name === '') { + this._tomSelect.clear(true); + } else { + if (!this._tomSelect.options[state.name]) { + this._tomSelect.addOption({name: state.name}); + } + this._tomSelect.setValue(state.name, true); + } + + if (this.hasSymbolTarget) { + this.symbolTarget.value = state.symbol; + this.symbolTarget.dispatchEvent(new Event('input')); + } + if (this.hasUnitTarget) { + this.unitTarget.value = state.unit; + this.unitTarget.dispatchEvent(new Event('input')); + } + + if (this.hasDefinitionTarget) { + this.setDefinition( + state.definitionId === '' ? null : state.definitionId, + state.definitionName + ); + } + this.applyInputDefinition( + state.inputType, + state.choices, + state.value, + state.deprecatedChoices, + ); + this.setLinkedFieldState(state.definitionId !== ''); + this._resetting = false; } connect() { + this.captureInitialState(); + this._form = this.nameTarget.form; + if (this._form) { + this._resetHandler = this.onFormReset.bind(this); + // Capture phase ensures callbacks emitted by the TomSelect reset plugins see _resetting=true. + this._form.addEventListener('reset', this._resetHandler, true); + } + const settings = { plugins: { 'autoselect_typed': {}, @@ -80,6 +384,10 @@ export default class extends Controller valueField: "name", clearAfterSelect: true, onItemAdd: this.onItemAdd.bind(this), + onItemRemove: this.onItemRemove.bind(this), + onInitialize: () => { + this._initialized = true; + }, render: { option: (data, escape) => { let tmp = '
' @@ -110,7 +418,16 @@ export default class extends Controller if (data.symbol !== undefined) { element.dataset.symbol = data.symbol; } - + if (data.definition_id !== undefined) { + element.dataset.definitionId = data.definition_id; + element.dataset.definitionName = data.name; + } + if (data.input_type !== undefined) { + element.dataset.inputType = data.input_type; + } + if (data.choices !== undefined) { + element.dataset.choices = JSON.stringify(data.choices); + } return element.outerHTML; } } @@ -133,11 +450,20 @@ export default class extends Controller } this._tomSelect = new TomSelect(this.nameTarget, settings); + this.setLinkedFieldState(this.hasDefinitionTarget && this.definitionTarget.value !== ''); + if (this.hasValueTextTarget && this.valueTextTarget.tagName === 'SELECT') { + this.setupValueTomSelect(this.valueTextTarget); + } } disconnect() { super.disconnect(); + clearTimeout(this._resetTimer); + if (this._form && this._resetHandler) { + this._form.removeEventListener('reset', this._resetHandler, true); + } //Destroy the TomSelect instance - this._tomSelect.destroy(); + this._tomSelect?.destroy(); + this.destroyValueTomSelect(); } } diff --git a/assets/js/tab_remember.js b/assets/js/tab_remember.js index 1bf35db5c..1b175f6e2 100644 --- a/assets/js/tab_remember.js +++ b/assets/js/tab_remember.js @@ -45,17 +45,23 @@ class TabRememberHelper { return; } - //Find the first offending element and show it - //Symfony validation errors can occur on multiple types + this.revealFirstValidationError(); + } + + revealFirstValidationError() { + // Symfony validation errors can occur on inputs or as standalone error blocks. const inputErrors = document.getElementsByClassName('is-invalid'); const blockErrors = document.getElementsByClassName('form-error-message'); - const merged = [...inputErrors, ...blockErrors]; + const firstElement = [...inputErrors, ...blockErrors][0] ?? null; - const first_element = merged[0] ?? null; - if(first_element) { - this.revealElementOnTab(first_element); - this.revealElementInCollapse(first_element); + if (!firstElement) { + return false; } + + this.revealElementOnTab(firstElement); + this.revealElementInCollapse(firstElement); + + return true; } /** @@ -102,21 +108,23 @@ class TabRememberHelper { } onLoad(event) { - //Determine which tab should be shown (use hash if specified, otherwise use localstorage) - let activeTab = null; - if (location.hash) { - activeTab = document.querySelector('[href=\'' + location.hash + '\']'); - } else if (localStorage.getItem('activeTab')) { - activeTab = document.querySelector('[href="' + localStorage.getItem('activeTab') + '"]'); - } - - if (activeTab) { - - //Reveal our tab selector (needed for nested tabs) - this.revealElementOnTab(activeTab); - - //Finally show the active tab itself - Tab.getOrCreateInstance(activeTab).show(); + // Validation errors take precedence over the remembered tab after a full-page invalid form response. + if (!this.revealFirstValidationError()) { + //Determine which tab should be shown (use hash if specified, otherwise use localstorage) + let activeTab = null; + if (location.hash) { + activeTab = document.querySelector('[href=\'' + location.hash + '\']'); + } else if (localStorage.getItem('activeTab')) { + activeTab = document.querySelector('[href="' + localStorage.getItem('activeTab') + '"]'); + } + + if (activeTab) { + //Reveal our tab selector (needed for nested tabs) + this.revealElementOnTab(activeTab); + + //Finally show the active tab itself + Tab.getOrCreateInstance(activeTab).show(); + } } //Register listener for tab change @@ -137,4 +145,4 @@ class TabRememberHelper { } -export default new TabRememberHelper(); \ No newline at end of file +export default new TabRememberHelper(); diff --git a/assets/tomselect/form_reset_handler/form_reset_handler.js b/assets/tomselect/form_reset_handler/form_reset_handler.js index c944d352e..ad5d56879 100644 --- a/assets/tomselect/form_reset_handler/form_reset_handler.js +++ b/assets/tomselect/form_reset_handler/form_reset_handler.js @@ -37,10 +37,14 @@ export default function form_reset_handler() { // 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', () => { + const form = input.form; + if (form) { + const resetHandler = () => { input.value = input.dataset.defaultValue ?? ''; self.sync(); - }); + }; + + form.addEventListener('reset', resetHandler); + self.on('destroy', () => form.removeEventListener('reset', resetHandler)); } } diff --git a/config/permissions.yaml b/config/permissions.yaml index b925330ae..450edc555 100644 --- a/config/permissions.yaml +++ b/config/permissions.yaml @@ -24,7 +24,8 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co label: "perm.read" # If a part can be read by a user, he can also see all the datastructures (except devices) alsoSet: ['storelocations.read', 'footprints.read', 'categories.read', 'suppliers.read', 'manufacturers.read', - 'currencies.read', 'attachment_types.read', 'measurement_units.read', 'part_custom_states.read'] + 'currencies.read', 'attachment_types.read', 'measurement_units.read', 'part_custom_states.read', + 'parameter_definitions.read'] apiTokenRole: ROLE_API_READ_ONLY edit: label: "perm.edit" @@ -140,6 +141,10 @@ perms: # Here comes a list with all Permission names (they have a perm_[name] co <<: *PART_CONTAINING label: "[[Part_custom_state]]" + parameter_definitions: + <<: *PART_CONTAINING + label: "[[Parameter_definition]]" + tools: label: "perm.part.tools" operations: diff --git a/migrations/Version20260816190000.php b/migrations/Version20260816190000.php new file mode 100644 index 000000000..5c1f44ec2 --- /dev/null +++ b/migrations/Version20260816190000.php @@ -0,0 +1,170 @@ +addSql(<<<'SQL' + CREATE TABLE parameter_definitions ( + id INT AUTO_INCREMENT NOT NULL, + name VARCHAR(255) NOT NULL, + normalized_name VARCHAR(255) NOT NULL, + input_type VARCHAR(16) DEFAULT 'text' NOT NULL, + choices JSON DEFAULT NULL, + symbol VARCHAR(20) NOT NULL, + unit VARCHAR(50) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + UNIQUE INDEX parameter_definition_normalized_name_unique (normalized_name), + INDEX parameter_definition_name_idx (name), + PRIMARY KEY (id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` + SQL); + $this->addSql('ALTER TABLE parameters ADD definition_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE parameters ADD CONSTRAINT FK_69348FED11EA911 FOREIGN KEY (definition_id) REFERENCES parameter_definitions (id) ON DELETE RESTRICT'); + $this->addSql('CREATE INDEX IDX_69348FED11EA911 ON parameters (definition_id)'); + $this->addSql('CREATE INDEX parameter_definition_value_idx ON parameters (definition_id, value_text, type, element_id)'); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE parameters DROP FOREIGN KEY FK_69348FED11EA911'); + $this->addSql('DROP INDEX IDX_69348FED11EA911 ON parameters'); + $this->addSql('DROP INDEX parameter_definition_value_idx ON parameters'); + $this->addSql('ALTER TABLE parameters DROP definition_id'); + $this->addSql('DROP TABLE parameter_definitions'); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE parameter_definitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + name VARCHAR(255) NOT NULL, + normalized_name VARCHAR(255) NOT NULL, + input_type VARCHAR(16) DEFAULT 'text' NOT NULL, + choices CLOB DEFAULT NULL, + symbol VARCHAR(20) NOT NULL, + unit VARCHAR(50) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL + ) + SQL); + $this->addSql('CREATE INDEX parameter_definition_name_idx ON parameter_definitions (name)'); + $this->addSql('CREATE UNIQUE INDEX parameter_definition_normalized_name_unique ON parameter_definitions (normalized_name)'); + + $this->addSql('CREATE TEMPORARY TABLE __temp__parameters AS SELECT id, symbol, value_min, value_typical, value_max, unit, value_text, param_group, name, last_modified, datetime_added, type, element_id, eda_visibility, eda_symbol_visibility FROM parameters'); + $this->addSql('DROP TABLE parameters'); + $this->addSql(<<<'SQL' + CREATE TABLE parameters ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + symbol VARCHAR(255) NOT NULL, + value_min DOUBLE PRECISION DEFAULT NULL, + value_typical DOUBLE PRECISION DEFAULT NULL, + value_max DOUBLE PRECISION DEFAULT NULL, + unit VARCHAR(255) NOT NULL, + value_text VARCHAR(255) NOT NULL, + param_group VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + type SMALLINT NOT NULL, + element_id INTEGER NOT NULL, + eda_visibility BOOLEAN DEFAULT NULL, + eda_symbol_visibility BOOLEAN DEFAULT NULL, + definition_id INTEGER DEFAULT NULL, + CONSTRAINT FK_69348FED11EA911 FOREIGN KEY (definition_id) REFERENCES parameter_definitions (id) ON DELETE RESTRICT NOT DEFERRABLE INITIALLY IMMEDIATE + ) + SQL); + $this->addSql('INSERT INTO parameters (id, symbol, value_min, value_typical, value_max, unit, value_text, param_group, name, last_modified, datetime_added, type, element_id, eda_visibility, eda_symbol_visibility) SELECT id, symbol, value_min, value_typical, value_max, unit, value_text, param_group, name, last_modified, datetime_added, type, element_id, eda_visibility, eda_symbol_visibility FROM __temp__parameters'); + $this->addSql('DROP TABLE __temp__parameters'); + $this->createSQLiteParameterIndexes(); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql('CREATE TEMPORARY TABLE __temp__parameters AS SELECT id, symbol, value_min, value_typical, value_max, unit, value_text, param_group, name, last_modified, datetime_added, type, element_id, eda_visibility, eda_symbol_visibility FROM parameters'); + $this->addSql('DROP TABLE parameters'); + $this->addSql(<<<'SQL' + CREATE TABLE parameters ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + symbol VARCHAR(255) NOT NULL, + value_min DOUBLE PRECISION DEFAULT NULL, + value_typical DOUBLE PRECISION DEFAULT NULL, + value_max DOUBLE PRECISION DEFAULT NULL, + unit VARCHAR(255) NOT NULL, + value_text VARCHAR(255) NOT NULL, + param_group VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + last_modified DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, + type SMALLINT NOT NULL, + element_id INTEGER NOT NULL, + eda_visibility BOOLEAN DEFAULT NULL, + eda_symbol_visibility BOOLEAN DEFAULT NULL + ) + SQL); + $this->addSql('INSERT INTO parameters (id, symbol, value_min, value_typical, value_max, unit, value_text, param_group, name, last_modified, datetime_added, type, element_id, eda_visibility, eda_symbol_visibility) SELECT id, symbol, value_min, value_typical, value_max, unit, value_text, param_group, name, last_modified, datetime_added, type, element_id, eda_visibility, eda_symbol_visibility FROM __temp__parameters'); + $this->addSql('DROP TABLE __temp__parameters'); + $this->addSql('CREATE INDEX parameter_type_element_idx ON parameters (type, element_id)'); + $this->addSql('CREATE INDEX parameter_group_idx ON parameters (param_group)'); + $this->addSql('CREATE INDEX parameter_name_idx ON parameters (name)'); + $this->addSql('CREATE INDEX IDX_69348FE1F1F2A24 ON parameters (element_id)'); + $this->addSql('DROP TABLE parameter_definitions'); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE parameter_definitions ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + name VARCHAR(255) NOT NULL, + normalized_name VARCHAR(255) NOT NULL, + input_type VARCHAR(16) DEFAULT 'text' NOT NULL, + choices JSON DEFAULT NULL, + symbol VARCHAR(20) NOT NULL, + unit VARCHAR(50) NOT NULL, + last_modified TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + datetime_added TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (id) + ) + SQL); + $this->addSql('CREATE INDEX parameter_definition_name_idx ON parameter_definitions (name)'); + $this->addSql('CREATE UNIQUE INDEX parameter_definition_normalized_name_unique ON parameter_definitions (normalized_name)'); + $this->addSql('ALTER TABLE parameters ADD definition_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE parameters ADD CONSTRAINT FK_69348FED11EA911 FOREIGN KEY (definition_id) REFERENCES parameter_definitions (id) ON DELETE RESTRICT NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_69348FED11EA911 ON parameters (definition_id)'); + $this->addSql('CREATE INDEX parameter_definition_value_idx ON parameters (definition_id, value_text, type, element_id)'); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE parameters DROP CONSTRAINT FK_69348FED11EA911'); + $this->addSql('DROP INDEX IDX_69348FED11EA911'); + $this->addSql('DROP INDEX parameter_definition_value_idx'); + $this->addSql('ALTER TABLE parameters DROP definition_id'); + $this->addSql('DROP TABLE parameter_definitions'); + } + + private function createSQLiteParameterIndexes(): void + { + $this->addSql('CREATE INDEX parameter_type_element_idx ON parameters (type, element_id)'); + $this->addSql('CREATE INDEX parameter_group_idx ON parameters (param_group)'); + $this->addSql('CREATE INDEX parameter_name_idx ON parameters (name)'); + $this->addSql('CREATE INDEX IDX_69348FE1F1F2A24 ON parameters (element_id)'); + $this->addSql('CREATE INDEX IDX_69348FED11EA911 ON parameters (definition_id)'); + $this->addSql('CREATE INDEX parameter_definition_value_idx ON parameters (definition_id, value_text, type, element_id)'); + } +} diff --git a/migrations/Version20260826000000.php b/migrations/Version20260826000000.php new file mode 100644 index 000000000..0b0f1b8c3 --- /dev/null +++ b/migrations/Version20260826000000.php @@ -0,0 +1,46 @@ +addSql('ALTER TABLE parameter_definitions ADD deprecated_choices JSON DEFAULT NULL'); + } + + public function mySQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE parameter_definitions DROP deprecated_choices'); + } + + public function sqLiteUp(Schema $schema): void + { + $this->addSql('ALTER TABLE parameter_definitions ADD deprecated_choices CLOB DEFAULT NULL'); + } + + public function sqLiteDown(Schema $schema): void + { + $this->addSql('ALTER TABLE parameter_definitions DROP COLUMN deprecated_choices'); + } + + public function postgreSQLUp(Schema $schema): void + { + $this->addSql('ALTER TABLE parameter_definitions ADD deprecated_choices JSON DEFAULT NULL'); + } + + public function postgreSQLDown(Schema $schema): void + { + $this->addSql('ALTER TABLE parameter_definitions DROP deprecated_choices'); + } +} diff --git a/src/ApiPlatform/ParameterDefinitionDeleteProcessor.php b/src/ApiPlatform/ParameterDefinitionDeleteProcessor.php new file mode 100644 index 000000000..cca2879b2 --- /dev/null +++ b/src/ApiPlatform/ParameterDefinitionDeleteProcessor.php @@ -0,0 +1,58 @@ + + */ +final readonly class ParameterDefinitionDeleteProcessor implements ProcessorInterface +{ + public function __construct( + private ManagerRegistry $managerRegistry, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private ProcessorInterface $removeProcessor, + ) { + } + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void + { + if (!$data instanceof ParameterDefinition) { + throw new \InvalidArgumentException('Expected a parameter definition.'); + } + + $repository = $this->managerRegistry->getRepository(AbstractParameter::class); + if (!$repository instanceof ParameterRepository) { + throw new \LogicException('The abstract parameter repository is not configured correctly.'); + } + + if ($repository->countByDefinition($data) > 0) { + throw new ConflictHttpException('This parameter definition is still in use and cannot be deleted.'); + } + + $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } +} diff --git a/src/Controller/AdminPages/BaseAdminController.php b/src/Controller/AdminPages/BaseAdminController.php index d89eb6ede..1221939cd 100644 --- a/src/Controller/AdminPages/BaseAdminController.php +++ b/src/Controller/AdminPages/BaseAdminController.php @@ -87,8 +87,8 @@ public function __construct(protected TranslatorInterface $translator, protected throw new InvalidArgumentException('You have to override the $entity_class, $form_class, $route_base and $twig_template value in your subclasss!'); } - if ('' === $this->attachment_class || !is_a($this->attachment_class, Attachment::class, true)) { - throw new InvalidArgumentException('You have to override the $attachment_class value with a valid Attachment class in your subclass!'); + if ('' !== $this->attachment_class && !is_a($this->attachment_class, Attachment::class, true)) { + throw new InvalidArgumentException('The $attachment_class value must be empty or a valid Attachment class!'); } if ('' === $this->parameter_class || ($this->parameter_class && !is_a($this->parameter_class, AbstractParameter::class, true))) { @@ -178,21 +178,23 @@ protected function _edit(AbstractNamedDBElement $entity, Request $request, Entit if ($form->isSubmitted() && $form->isValid()) { if ($this->additionalActionEdit($form, $entity)) { //Upload passed files - $attachments = $form['attachments']; - foreach ($attachments as $attachment) { - /** @var FormInterface $attachment */ - try { - $this->attachmentSubmitHandler->handleUpload( - $attachment->getData(), - AttachmentUpload::fromAttachmentForm($attachment) - ); - } catch (AttachmentDownloadException $attachmentDownloadException) { - $this->addFlash( - 'error', - $this->translator->trans( - 'attachment.download_failed' - ).' '.$attachmentDownloadException->getMessage() - ); + if ($form->has('attachments')) { + $attachments = $form['attachments']; + foreach ($attachments as $attachment) { + /** @var FormInterface $attachment */ + try { + $this->attachmentSubmitHandler->handleUpload( + $attachment->getData(), + AttachmentUpload::fromAttachmentForm($attachment) + ); + } catch (AttachmentDownloadException $attachmentDownloadException) { + $this->addFlash( + 'error', + $this->translator->trans( + 'attachment.download_failed' + ).' '.$attachmentDownloadException->getMessage() + ); + } } } @@ -275,22 +277,24 @@ protected function _new(Request $request, EntityManagerInterface $em, EntityImpo //Perform additional actions if ($form->isSubmitted() && $form->isValid() && $this->additionalActionNew($form, $new_entity)) { //Upload passed files - $attachments = $form['attachments']; - foreach ($attachments as $attachment) { - /** @var FormInterface $attachment */ - - try { - $this->attachmentSubmitHandler->handleUpload( - $attachment->getData(), - AttachmentUpload::fromAttachmentForm($attachment) - ); - } catch (AttachmentDownloadException $attachmentDownloadException) { - $this->addFlash( - 'error', - $this->translator->trans( - 'attachment.download_failed' - ).' '.$attachmentDownloadException->getMessage() - ); + if ($form->has('attachments')) { + $attachments = $form['attachments']; + foreach ($attachments as $attachment) { + /** @var FormInterface $attachment */ + + try { + $this->attachmentSubmitHandler->handleUpload( + $attachment->getData(), + AttachmentUpload::fromAttachmentForm($attachment) + ); + } catch (AttachmentDownloadException $attachmentDownloadException) { + $this->addFlash( + 'error', + $this->translator->trans( + 'attachment.download_failed' + ).' '.$attachmentDownloadException->getMessage() + ); + } } } diff --git a/src/Controller/AdminPages/ParameterDefinitionController.php b/src/Controller/AdminPages/ParameterDefinitionController.php new file mode 100644 index 000000000..d619f02c4 --- /dev/null +++ b/src/Controller/AdminPages/ParameterDefinitionController.php @@ -0,0 +1,92 @@ +_delete($request, $entity, $recursionHelper); + } + + #[Route(path: '/{id}/edit/{timestamp}', name: 'parameter_definition_edit', requirements: ['id' => '\d+'])] + #[Route(path: '/{id}', requirements: ['id' => '\d+'])] + public function edit(ParameterDefinition $entity, Request $request, EntityManagerInterface $em, ?string $timestamp = null): Response + { + return $this->_edit($entity, $request, $em, $timestamp); + } + + #[Route(path: '/new', name: 'parameter_definition_new')] + #[Route(path: '/{id}/clone', name: 'parameter_definition_clone')] + #[Route(path: '/')] + public function new(Request $request, EntityManagerInterface $em, EntityImporter $importer, ?ParameterDefinition $entity = null): Response + { + return $this->_new($request, $em, $importer, $entity); + } + + #[Route(path: '/export', name: 'parameter_definition_export_all')] + public function exportAll(EntityManagerInterface $em, EntityExporter $exporter, Request $request): Response + { + return $this->_exportAll($em, $exporter, $request); + } + + #[Route(path: '/{id}/export', name: 'parameter_definition_export')] + public function exportEntity(ParameterDefinition $entity, EntityExporter $exporter, Request $request): Response + { + return $this->_exportEntity($entity, $exporter, $request); + } + + protected function deleteCheck(AbstractNamedDBElement $entity): bool + { + if ($entity instanceof ParameterDefinition) { + $repository = $this->entityManager->getRepository(AbstractParameter::class); + if (!$repository instanceof ParameterRepository) { + throw new \LogicException('The abstract parameter repository is not configured correctly.'); + } + + if ($repository->countByDefinition($entity) > 0) { + $this->addFlash('error', 'parameter_definition.delete.in_use'); + + return false; + } + } + + return parent::deleteCheck($entity); + } +} diff --git a/src/Controller/PartController.php b/src/Controller/PartController.php index b4b450088..fb9aaef16 100644 --- a/src/Controller/PartController.php +++ b/src/Controller/PartController.php @@ -47,6 +47,7 @@ use App\Services\LogSystem\HistoryHelper; use App\Services\LogSystem\TimeTravel; use App\Services\Parameters\ParameterExtractor; +use App\Services\Parameters\PendingParameterChoiceApplier; use App\Services\Parts\PartLotWithdrawAddHelper; use App\Services\Parts\PricedetailHelper; use App\Services\ProjectSystem\ProjectBuildPartHelper; @@ -82,6 +83,7 @@ public function __construct( private readonly EventCommentHelper $commentHelper, private readonly PartInfoSettings $partInfoSettings, private readonly IpnSuggestSettings $ipnSuggestSettings, + private readonly PendingParameterChoiceApplier $pendingParameterChoiceApplier, ) { } @@ -571,6 +573,10 @@ private function renderPartForm(string $mode, Request $request, Part $data, arra $this->commentHelper->setMessage($form['log_comment']->getData()); + // Apply definition changes only after the complete Part form is valid. The following flush persists the + // Part and its definition changes atomically. + $this->pendingParameterChoiceApplier->apply($new_part); + $this->em->persist($new_part); //When we are in merge mode, we have to remove the other part diff --git a/src/Controller/TypeaheadController.php b/src/Controller/TypeaheadController.php index f7e15b6da..3d4cccf1c 100644 --- a/src/Controller/TypeaheadController.php +++ b/src/Controller/TypeaheadController.php @@ -30,6 +30,7 @@ use App\Entity\Parameters\GroupParameter; use App\Entity\Parameters\ManufacturerParameter; use App\Entity\Parameters\MeasurementUnitParameter; +use App\Entity\Parameters\ParameterDefinition; use App\Entity\Parameters\PartParameter; use App\Entity\Parameters\ProjectParameter; use App\Entity\Parameters\StorageLocationParameter; @@ -176,9 +177,61 @@ public function parameters(string $type, EntityManagerInterface $entityManager, $data = $repository->autocompleteParamName($query); + if (PartParameter::class === $class) { + $definition_repository = $entityManager->getRepository(ParameterDefinition::class); + $data = $this->mergeParameterDefinitionSuggestions( + $definition_repository->autocompleteForParameterEditor($query), + $data, + ); + } + return new JsonResponse($data); } + /** + * Global definitions take precedence over legacy parameter suggestions with the same case-insensitive name. + * + * @param list|null, + * deprecated_choices: list|null + * }> $definitions + * @param array $legacy_parameters + * @return list> + */ + private function mergeParameterDefinitionSuggestions(array $definitions, array $legacy_parameters): array + { + $result = []; + $known_names = []; + + foreach ($definitions as $definition) { + $definition['choices'] ??= []; + $definition['deprecated_choices'] ??= []; + $result[] = $definition; + $known_names[mb_strtolower(trim($definition['name']))] = true; + } + + foreach ($legacy_parameters as $legacy_parameter) { + if (50 <= count($result)) { + break; + } + + $normalized_name = mb_strtolower(trim($legacy_parameter['name'])); + if (isset($known_names[$normalized_name])) { + continue; + } + + $result[] = $legacy_parameter; + $known_names[$normalized_name] = true; + } + + return array_slice($result, 0, 50); + } + #[Route(path: '/tags/search/{query}', name: 'typeahead_tags', requirements: ['query' => '.+'])] public function tags(string $query, TagFinder $finder): JsonResponse { diff --git a/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php b/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php index ea1bd5a0a..d3c0b9ca2 100644 --- a/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php +++ b/src/DataTables/Filters/Constraints/Part/ParameterConstraint.php @@ -24,6 +24,7 @@ use App\DataTables\Filters\Constraints\AbstractConstraint; use App\DataTables\Filters\Constraints\TextConstraint; +use App\Entity\Parameters\ParameterDefinition; use App\Entity\Parameters\PartParameter; use Doctrine\ORM\QueryBuilder; @@ -35,6 +36,8 @@ class ParameterConstraint extends AbstractConstraint protected string $unit = ''; + protected ?ParameterDefinition $definition = null; + protected TextConstraint $value_text; protected ParameterValueConstraint $value; @@ -54,11 +57,29 @@ public function __construct() public function isEnabled(): bool { - return true; + if ($this->definition instanceof ParameterDefinition) { + if (ParameterDefinition::INPUT_TYPE_CHOICE === $this->definition->getInputType()) { + return $this->value_text->isEnabled() + && '=' === $this->value_text->getOperator() + && '' !== trim((string) $this->value_text->getValue()); + } + + return $this->value_text->isEnabled() || $this->value->isEnabled(); + } + + return '' !== trim($this->name) + || '' !== trim($this->symbol) + || '' !== trim($this->unit) + || $this->value_text->isEnabled() + || $this->value->isEnabled(); } public function apply(QueryBuilder $queryBuilder): void { + if (!$this->isEnabled()) { + return; + } + //Create a new qb to build the subquery $subqb = new QueryBuilder($queryBuilder->getEntityManager()); @@ -68,7 +89,48 @@ public function apply(QueryBuilder $queryBuilder): void ->from(PartParameter::class, $this->alias) ->where($this->alias . '.element = part'); - if ($this->name !== '') { + $value_text_applied = false; + if ($this->definition instanceof ParameterDefinition) { + $definition_param = $this->generateParameterIdentifier('params.definition'); + $definition_name_param = $this->generateParameterIdentifier('params.definition_name'); + + if (ParameterDefinition::INPUT_TYPE_CHOICE === $this->definition->getInputType() + && '=' === $this->value_text->getOperator() + && $this->value_text->isEnabled()) { + $value_param = $this->generateParameterIdentifier('params.value_text'); + $legacy_value_param = $this->generateParameterIdentifier('params.legacy_value_text'); + $subqb->andWhere(sprintf( + '((%1$s.definition = :%2$s AND %1$s.value_text = :%4$s) OR ' + .'(%1$s.definition IS NULL AND ILIKE(TRIM(%1$s.name), :%3$s) = TRUE ' + .'AND ILIKE(TRIM(%1$s.value_text), :%5$s) = TRUE))', + $this->alias, + $definition_param, + $definition_name_param, + $value_param, + $legacy_value_param, + )); + $subqb->setParameter($value_param, $this->value_text->getValue()); + $subqb->setParameter( + $legacy_value_param, + $this->escapeLikeValue(trim((string) $this->value_text->getValue())), + ); + $value_text_applied = true; + } else { + $subqb->andWhere(sprintf( + '(%1$s.definition = :%2$s OR ' + .'(%1$s.definition IS NULL AND ILIKE(TRIM(%1$s.name), :%3$s) = TRUE))', + $this->alias, + $definition_param, + $definition_name_param, + )); + } + + $subqb->setParameter($definition_param, $this->definition); + $subqb->setParameter( + $definition_name_param, + $this->escapeLikeValue(trim($this->definition->getName())), + ); + } elseif ($this->name !== '') { $paramName = $this->generateParameterIdentifier('params.name'); $subqb->andWhere($this->alias . '.name = :' . $paramName); $queryBuilder->setParameter($paramName, $this->name); @@ -87,8 +149,13 @@ public function apply(QueryBuilder $queryBuilder): void } //Apply all subfilters - $this->value_text->apply($subqb); - $this->value->apply($subqb); + if (!$value_text_applied) { + $this->value_text->apply($subqb); + } + if (!$this->definition instanceof ParameterDefinition + || ParameterDefinition::INPUT_TYPE_CHOICE !== $this->definition->getInputType()) { + $this->value->apply($subqb); + } //Copy all parameters from the subquery to the main query //We can not use setParameters here, as this would override the exiting paramaters in queryBuilder @@ -132,6 +199,23 @@ public function setUnit(string $unit): self return $this; } + public function getDefinition(): ?ParameterDefinition + { + return $this->definition; + } + + public function setDefinition(?ParameterDefinition $definition): ParameterConstraint + { + $this->definition = $definition; + + return $this; + } + + private function escapeLikeValue(string $value): string + { + return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $value); + } + public function getValueText(): TextConstraint { return $this->value_text; diff --git a/src/Entity/LogSystem/LogTargetType.php b/src/Entity/LogSystem/LogTargetType.php index 3b2d8682a..cb3284983 100644 --- a/src/Entity/LogSystem/LogTargetType.php +++ b/src/Entity/LogSystem/LogTargetType.php @@ -28,6 +28,7 @@ use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parameters\AbstractParameter; +use App\Entity\Parameters\ParameterDefinition; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -73,6 +74,7 @@ enum LogTargetType: int case BULK_INFO_PROVIDER_IMPORT_JOB = 21; case BULK_INFO_PROVIDER_IMPORT_JOB_PART = 22; case PART_CUSTOM_STATE = 23; + case PARAMETER_DEFINITION = 24; /** * Returns the class name of the target type or null if the target type is NONE. @@ -104,7 +106,8 @@ public function toClass(): ?string self::PART_ASSOCIATION => PartAssociation::class, self::BULK_INFO_PROVIDER_IMPORT_JOB => BulkInfoProviderImportJob::class, self::BULK_INFO_PROVIDER_IMPORT_JOB_PART => BulkInfoProviderImportJobPart::class, - self::PART_CUSTOM_STATE => PartCustomState::class + self::PART_CUSTOM_STATE => PartCustomState::class, + self::PARAMETER_DEFINITION => ParameterDefinition::class, }; } diff --git a/src/Entity/Parameters/AbstractParameter.php b/src/Entity/Parameters/AbstractParameter.php index a0598637c..a5f41c637 100644 --- a/src/Entity/Parameters/AbstractParameter.php +++ b/src/Entity/Parameters/AbstractParameter.php @@ -46,6 +46,7 @@ use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; @@ -64,10 +65,12 @@ use Symfony\Component\Serializer\Attribute\SerializedName; use Symfony\Component\Serializer\Attribute\DiscriminatorMap; use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Context\ExecutionContextInterface; use function sprintf; #[ORM\Entity(repositoryClass: ParameterRepository::class)] +#[ORM\HasLifecycleCallbacks] #[ORM\InheritanceType('SINGLE_TABLE')] #[ORM\DiscriminatorColumn(name: 'type', type: 'smallint')] #[ORM\DiscriminatorMap([0 => CategoryParameter::class, 1 => CurrencyParameter::class, 2 => ProjectParameter::class, @@ -79,6 +82,7 @@ #[ORM\Index(name: 'parameter_name_idx', columns: ['name'])] #[ORM\Index(name: 'parameter_group_idx', columns: ['param_group'])] #[ORM\Index(name: 'parameter_type_element_idx', columns: ['type', 'element_id'])] +#[ORM\Index(name: 'parameter_definition_value_idx', columns: ['definition_id', 'value_text', 'type', 'element_id'])] #[ApiResource( shortName: 'Parameter', operations: [ @@ -98,7 +102,6 @@ #[DiscriminatorMap(typeProperty: '_type', mapping: self::API_DISCRIMINATOR_MAP)] abstract class AbstractParameter extends AbstractNamedDBElement implements UniqueValidatableInterface { - /* * The discriminator map used for API platform. The key should be the same as the api platform short type (the @type JSONLD field). */ @@ -164,6 +167,32 @@ abstract class AbstractParameter extends AbstractNamedDBElement implements Uniqu #[Assert\Length(max: 255)] protected string $value_text = ''; + /** + * Optional global definition. Name, symbol and unit remain persisted snapshots for historical views, while + * input type and choices are always read from the linked definition. + */ + #[ApiProperty(readableLink: false, writableLink: false)] + #[Groups(['parameter:read', 'parameter:write'])] + #[ORM\ManyToOne(targetEntity: ParameterDefinition::class, inversedBy: 'parameter_usages')] + #[ORM\JoinColumn(name: 'definition_id', nullable: true, onDelete: 'RESTRICT')] + protected ?ParameterDefinition $definition = null; + + /** + * A choice explicitly requested from the Part editor. This is deliberately not persisted: the definition is + * updated only after the complete Part form has passed validation. + */ + private ?string $pending_definition_choice = null; + + /** + * Persisted assignment snapshot used to distinguish preserving a historical deprecated value from assigning one. + * These fields are deliberately transient and are populated only by Doctrine lifecycle callbacks. + */ + private bool $has_persisted_choice_assignment = false; + + private ?int $persisted_definition_id = null; + + private string $persisted_value_text = ''; + /** * @var string the group this parameter belongs to */ @@ -202,6 +231,24 @@ public function __construct() } } + public function __clone() + { + parent::__clone(); + $this->has_persisted_choice_assignment = false; + $this->persisted_definition_id = null; + $this->persisted_value_text = ''; + } + + #[ORM\PostLoad] + #[ORM\PostPersist] + #[ORM\PostUpdate] + public function capturePersistedChoiceAssignment(): void + { + $this->has_persisted_choice_assignment = true; + $this->persisted_definition_id = $this->definition?->getID(); + $this->persisted_value_text = $this->value_text; + } + public function updateTimestamps(): void { parent::updateTimestamps(); @@ -218,6 +265,86 @@ public function getElement(): ?AbstractDBElement return $this->element; } + /** + * Returns the optional global definition linked to this parameter. + */ + public function getDefinition(): ?ParameterDefinition + { + return $this->definition; + } + + /** + * Links a definition and captures its current name, symbol and unit as historical snapshots. + */ + public function setDefinition(?ParameterDefinition $definition): self + { + $this->synchronizeDefinitionReference($definition); + if ($definition instanceof ParameterDefinition) { + $this->refreshSnapshotFromDefinition(); + + if (ParameterDefinition::INPUT_TYPE_CHOICE === $definition->getInputType() && '' !== $this->value_text) { + $canonical_choice = $definition->findCanonicalChoice($this->value_text); + if (null !== $canonical_choice) { + $this->value_text = $canonical_choice; + } + } + } + + return $this; + } + + /** + * Restores only the historical association without replacing the independently persisted metadata snapshots. + * This is intended for the TimeTravel infrastructure. + */ + public function restoreDefinitionReference(?ParameterDefinition $definition): self + { + $this->synchronizeDefinitionReference($definition); + + return $this; + } + + private function synchronizeDefinitionReference(?ParameterDefinition $definition): void + { + if ($this->definition === $definition) { + $definition?->addParameterUsage($this); + + return; + } + + $previous_definition = $this->definition; + $this->definition = $definition; + + $previous_definition?->removeParameterUsage($this); + $definition?->addParameterUsage($this); + } + + /** + * Explicitly refreshes the persisted name, symbol and unit snapshots from the linked definition. + */ + public function refreshSnapshotFromDefinition(): self + { + if (!$this->definition instanceof ParameterDefinition) { + return $this; + } + + $this->name = $this->definition->getName(); + $this->symbol = $this->definition->getSymbol(); + $this->unit = $this->definition->getUnit(); + + return $this; + } + + public function getSnapshotName(): string + { + return $this->name; + } + + public function getEffectiveName(): string + { + return $this->definition?->getName() ?? $this->name; + } + /** * Return a formatted string version of the values of the string. * Based on the set values it can return something like this: 34 V (12 V ... 50 V) [Text]. @@ -225,6 +352,19 @@ public function getElement(): ?AbstractDBElement #[Groups(['parameter:read', 'full'])] #[SerializedName('formatted')] public function getFormattedValue(bool $latex_formatted = false): string + { + return $this->formatValue($this->unit, $latex_formatted); + } + + /** + * Formats the current value using metadata from the linked definition when available. + */ + public function getEffectiveFormattedValue(bool $latex_formatted = false): string + { + return $this->formatValue($this->getEffectiveUnit(), $latex_formatted); + } + + private function formatValue(string $unit, bool $latex_formatted): string { //If we just only have text value, return early if (null === $this->value_typical && null === $this->value_min && null === $this->value_max) { @@ -234,7 +374,7 @@ public function getFormattedValue(bool $latex_formatted = false): string $str = ''; $bracket_opened = false; if ($this->value_typical !== null) { - $str .= $this->getValueTypicalWithUnit($latex_formatted); + $str .= $this->formatWithExplicitUnit($this->value_typical, $unit, with_latex: $latex_formatted); if ($this->value_min || $this->value_max) { $bracket_opened = true; $str .= ' ('; @@ -242,11 +382,12 @@ public function getFormattedValue(bool $latex_formatted = false): string } if ($this->value_max !== null && $this->value_min !== null) { - $str .= $this->getValueMinWithUnit($latex_formatted).' ... '.$this->getValueMaxWithUnit($latex_formatted); + $str .= $this->formatWithExplicitUnit($this->value_min, $unit, with_latex: $latex_formatted).' ... ' + .$this->formatWithExplicitUnit($this->value_max, $unit, with_latex: $latex_formatted); } elseif ($this->value_max !== null) { - $str .= 'max. '.$this->getValueMaxWithUnit($latex_formatted); + $str .= 'max. '.$this->formatWithExplicitUnit($this->value_max, $unit, with_latex: $latex_formatted); } elseif ($this->value_min !== null) { - $str .= 'min. '.$this->getValueMinWithUnit($latex_formatted); + $str .= 'min. '.$this->formatWithExplicitUnit($this->value_min, $unit, with_latex: $latex_formatted); } //Add closing bracket @@ -317,6 +458,16 @@ public function getSymbol(): string return $this->symbol; } + public function getSnapshotSymbol(): string + { + return $this->symbol; + } + + public function getEffectiveSymbol(): string + { + return $this->definition?->getSymbol() ?? $this->symbol; + } + /** * Sets the mathematical symbol for this specification (e.g. "V_CB"). * @@ -422,6 +573,16 @@ public function getUnit(): string return $this->unit; } + public function getSnapshotUnit(): string + { + return $this->unit; + } + + public function getEffectiveUnit(): string + { + return $this->definition?->getUnit() ?? $this->unit; + } + /** * Sets the unit used by the value. * @@ -447,31 +608,93 @@ public function getValueText(): string * * @return $this */ - public function setValueText(string $value_text): self + public function setValueText(?string $value_text): self { + $value_text ??= ''; + + if ($this->definition instanceof ParameterDefinition + && ParameterDefinition::INPUT_TYPE_CHOICE === $this->definition->getInputType() + && '' !== $value_text) { + $canonical_choice = $this->definition->findCanonicalChoice($value_text); + if (null !== $canonical_choice) { + $value_text = $canonical_choice; + } + } + $this->value_text = $value_text; return $this; } + public function requestPendingDefinitionChoice(?string $choice): self + { + $choice = null === $choice ? '' : trim($choice); + $this->pending_definition_choice = '' === $choice ? null : $choice; + + return $this; + } + + public function getPendingDefinitionChoice(): ?string + { + return $this->pending_definition_choice; + } + + public function clearPendingDefinitionChoice(): self + { + $this->pending_definition_choice = null; + + return $this; + } + + #[Groups(['parameter:read'])] + #[SerializedName('input_type')] + public function getEffectiveInputType(): string + { + return $this->definition?->getInputType() ?? ParameterDefinition::INPUT_TYPE_TEXT; + } + + /** @return list */ + #[Groups(['parameter:read'])] + #[SerializedName('choices')] + public function getEffectiveChoices(): array + { + return $this->definition?->getChoices() ?? []; + } + + public function hasEffectiveChoices(): bool + { + return ParameterDefinition::INPUT_TYPE_CHOICE === $this->getEffectiveInputType() + && [] !== $this->getEffectiveChoices(); + } + + public function getEffectiveChoicesText(): string + { + return implode("\n", $this->getEffectiveChoices()); + } + /** * Return a string representation and (if possible) with its unit. */ protected function formatWithUnit(float $value, string $format = '%g', bool $with_latex = false): string + { + return $this->formatWithExplicitUnit($value, $this->unit, $format, $with_latex); + } + + private function formatWithExplicitUnit(float $value, string $unit, string $format = '%g', bool $with_latex = false): string { $str = sprintf($format, $value); - if ($this->unit !== '') { + if ('' !== $unit) { if (!$with_latex) { - $unit = $this->unit; + $formatted_unit = $unit; } else { //Escape the percentage sign for convenience (as latex uses it as comment and it is often used in units) - $escaped = preg_replace('/\\\\?%/', "\\\\%", $this->unit); + $escaped = preg_replace('/\\\\?%/', "\\\\%", $unit); - $unit = '$\mathrm{'.$escaped.'}$'; + $formatted_unit = '$\mathrm{'.$escaped.'}$'; } - return $str.' '.$unit; + return $str.' '.$formatted_unit; } return $str; @@ -516,8 +739,51 @@ public function setEdaSymbolVisibility(?bool $eda_symbol_visibility): self return $this; } + #[Assert\Callback] + public function validateDefinitionUsage(ExecutionContextInterface $context): void + { + if (!$this->definition instanceof ParameterDefinition + || ParameterDefinition::INPUT_TYPE_CHOICE !== $this->definition->getInputType() + || '' === $this->value_text) { + return; + } + + $canonical_choice = $this->definition->findCanonicalChoice($this->value_text); + if (null !== $canonical_choice) { + if ($canonical_choice !== $this->value_text) { + $context->buildViolation('parameter.validator.value_not_canonical') + ->atPath('value_text') + ->addViolation(); + } + + return; + } + + $deprecated_choice = $this->definition->findCanonicalDeprecatedChoice($this->value_text); + if (null !== $deprecated_choice && $this->isPreservingPersistedDeprecatedChoice($deprecated_choice)) { + return; + } + + if ($this->pending_definition_choice !== $this->value_text) { + $context->buildViolation('parameter.validator.value_not_allowed') + ->atPath('value_text') + ->addViolation(); + } + } + + private function isPreservingPersistedDeprecatedChoice(string $deprecated_choice): bool + { + if (!$this->has_persisted_choice_assignment + || null === $this->persisted_definition_id + || $this->persisted_definition_id !== $this->definition?->getID()) { + return false; + } + + return $deprecated_choice === $this->definition->findCanonicalDeprecatedChoice($this->persisted_value_text); + } + public function getComparableFields(): array { - return ['name' => $this->name, 'group' => $this->group, 'element' => $this->element?->getId()]; + return ['name' => $this->getEffectiveName(), 'group' => $this->group, 'element' => $this->element?->getId()]; } } diff --git a/src/Entity/Parameters/ParameterDefinition.php b/src/Entity/Parameters/ParameterDefinition.php new file mode 100644 index 000000000..ba6805385 --- /dev/null +++ b/src/Entity/Parameters/ParameterDefinition.php @@ -0,0 +1,454 @@ + ['parameter_definition:read', 'api:basic:read'], 'openapi_definition_name' => 'Read'], + denormalizationContext: ['groups' => ['parameter_definition:write', 'api:basic:write'], 'openapi_definition_name' => 'Write'], +)] +#[ApiFilter(PropertyFilter::class)] +#[ApiFilter(LikeFilter::class, properties: ['name', 'symbol', 'unit'])] +#[ApiFilter(DateFilter::class, strategy: DateFilterInterface::EXCLUDE_NULL)] +#[ApiFilter(OrderFilter::class, properties: ['name', 'id', 'addedDate', 'lastModified'])] +class ParameterDefinition extends AbstractNamedDBElement +{ + public const INPUT_TYPE_TEXT = 'text'; + public const INPUT_TYPE_CHOICE = 'choice'; + public const MAX_CHOICE_LENGTH = 255; + + #[ORM\Column(type: Types::STRING, length: 255)] + private string $normalized_name = ''; + + #[ORM\Column(type: Types::STRING, length: 16, options: ['default' => self::INPUT_TYPE_TEXT])] + #[Assert\Choice(choices: [self::INPUT_TYPE_TEXT, self::INPUT_TYPE_CHOICE])] + #[Groups(['full', 'import', 'parameter_definition:read', 'parameter_definition:write'])] + private string $input_type = self::INPUT_TYPE_TEXT; + + /** @var list|null */ + #[ORM\Column(type: Types::JSON, nullable: true)] + #[Groups(['full', 'import', 'parameter_definition:read', 'parameter_definition:write'])] + private ?array $choices = null; + + /** @var list|null */ + #[ORM\Column(type: Types::JSON, nullable: true)] + #[Groups(['full', 'import', 'parameter_definition:read'])] + private ?array $deprecated_choices = null; + + private bool $choices_contain_invalid_values = false; + + private bool $deprecated_choices_contain_invalid_values = false; + + #[ORM\Column(type: Types::STRING, length: 20)] + #[Assert\Length(max: 20)] + #[Groups(['full', 'import', 'parameter_definition:read', 'parameter_definition:write'])] + private string $symbol = ''; + + #[ORM\Column(type: Types::STRING, length: 50)] + #[Assert\Length(max: 50)] + #[Groups(['full', 'import', 'parameter_definition:read', 'parameter_definition:write'])] + private string $unit = ''; + + /** @var Collection */ + #[ORM\OneToMany(mappedBy: 'definition', targetEntity: AbstractParameter::class)] + private Collection $parameter_usages; + + public function __construct() + { + $this->parameter_usages = new ArrayCollection(); + } + + public function setName(string $new_name): self + { + $new_name = trim($new_name); + parent::setName($new_name); + $this->normalized_name = self::normalize($new_name); + + return $this; + } + + #[ORM\PrePersist] + #[ORM\PreUpdate] + public function updateNormalizedName(): void + { + $this->normalized_name = self::normalize($this->name); + } + + public function getNormalizedName(): string + { + return $this->normalized_name; + } + + public function getInputType(): string + { + return $this->input_type; + } + + public function setInputType(string $input_type): self + { + if (!in_array($input_type, [self::INPUT_TYPE_TEXT, self::INPUT_TYPE_CHOICE], true)) { + throw new InvalidArgumentException(sprintf('Unsupported parameter input type "%s".', $input_type)); + } + + $this->input_type = $input_type; + if (self::INPUT_TYPE_TEXT === $input_type) { + $this->choices = null; + $this->deprecated_choices = null; + $this->choices_contain_invalid_values = false; + $this->deprecated_choices_contain_invalid_values = false; + } + + return $this; + } + + /** @return list */ + public function getChoices(): array + { + return $this->choices ?? []; + } + + /** @param list|null $choices */ + public function setChoices(?array $choices): self + { + $canonical_choices = self::canonicalizeChoices( + $choices ?? [], + $this->choices_contain_invalid_values, + ); + + // Reactivating a deprecated value preserves its historical canonical spelling. + foreach ($canonical_choices as $index => $choice) { + $deprecated_choice = $this->findCanonicalDeprecatedChoice($choice); + if (null !== $deprecated_choice) { + $canonical_choices[$index] = $deprecated_choice; + } + } + + $active_normalized = []; + foreach ($canonical_choices as $choice) { + $active_normalized[self::normalize($choice)] = true; + } + + $deprecated_choices = array_values(array_filter( + $this->getDeprecatedChoices(), + static fn (string $choice): bool => !isset($active_normalized[self::normalize($choice)]), + )); + $deprecated_normalized = []; + foreach ($deprecated_choices as $choice) { + $deprecated_normalized[self::normalize($choice)] = true; + } + + foreach ($this->getChoices() as $previous_choice) { + $normalized_choice = self::normalize($previous_choice); + if (isset($active_normalized[$normalized_choice]) + || isset($deprecated_normalized[$normalized_choice])) { + continue; + } + + $deprecated_choices[] = $previous_choice; + $deprecated_normalized[$normalized_choice] = true; + } + + $this->choices = [] === $canonical_choices ? null : $canonical_choices; + $this->deprecated_choices = [] === $deprecated_choices ? null : $deprecated_choices; + + return $this; + } + + /** @return list */ + public function getDeprecatedChoices(): array + { + return $this->deprecated_choices ?? []; + } + + /** + * Used by import and historical restoration. API clients cannot write this field directly. + * + * @param list|null $deprecated_choices + */ + public function setDeprecatedChoices(?array $deprecated_choices): self + { + $active_normalized = []; + foreach ($this->getChoices() as $choice) { + $active_normalized[self::normalize($choice)] = true; + } + + $canonical_choices = array_values(array_filter( + self::canonicalizeChoices( + $deprecated_choices ?? [], + $this->deprecated_choices_contain_invalid_values, + ), + static fn (string $choice): bool => !isset($active_normalized[self::normalize($choice)]), + )); + $this->deprecated_choices = [] === $canonical_choices ? null : $canonical_choices; + + return $this; + } + + /** @return list */ + public function getKnownChoices(): array + { + return [...$this->getChoices(), ...$this->getDeprecatedChoices()]; + } + + public function getChoicesText(): string + { + return implode("\n", $this->getChoices()); + } + + public function setChoicesText(?string $choices_text): self + { + if (null === $choices_text || '' === trim($choices_text)) { + return $this->setChoices(null); + } + + return $this->setChoices(preg_split('/\R/', $choices_text) ?: []); + } + + public function addChoice(string $choice): string + { + if (self::INPUT_TYPE_CHOICE !== $this->input_type) { + throw new LogicException('Choices can only be added to a choice parameter definition.'); + } + + $choice = trim($choice); + if ('' === $choice) { + throw new InvalidArgumentException('A parameter choice must not be empty.'); + } + if (mb_strlen($choice) > self::MAX_CHOICE_LENGTH) { + throw new InvalidArgumentException(sprintf('A parameter choice must not exceed %d characters.', self::MAX_CHOICE_LENGTH)); + } + + $canonical_choice = $this->findCanonicalChoice($choice); + if (null !== $canonical_choice) { + return $canonical_choice; + } + + $deprecated_choice = $this->findCanonicalDeprecatedChoice($choice); + if (null !== $deprecated_choice) { + $choices = $this->getChoices(); + $choices[] = $deprecated_choice; + $this->choices = $choices; + $normalized_choice = self::normalize($deprecated_choice); + $deprecated_choices = array_values(array_filter( + $this->getDeprecatedChoices(), + static fn (string $candidate): bool => self::normalize($candidate) !== $normalized_choice, + )); + $this->deprecated_choices = [] === $deprecated_choices ? null : $deprecated_choices; + + return $deprecated_choice; + } + + $choices = $this->getChoices(); + $choices[] = $choice; + $this->choices = $choices; + + return $choice; + } + + public function findCanonicalChoice(string $choice): ?string + { + return self::findCanonicalInChoices($choice, $this->getChoices()); + } + + public function findCanonicalDeprecatedChoice(string $choice): ?string + { + return self::findCanonicalInChoices($choice, $this->getDeprecatedChoices()); + } + + public function findCanonicalKnownChoice(string $choice): ?string + { + return $this->findCanonicalChoice($choice) ?? $this->findCanonicalDeprecatedChoice($choice); + } + + public function getSymbol(): string + { + return $this->symbol; + } + + public function setSymbol(string $symbol): self + { + $this->symbol = $symbol; + + return $this; + } + + public function getUnit(): string + { + return $this->unit; + } + + public function setUnit(string $unit): self + { + $this->unit = $unit; + + return $this; + } + + /** @return Collection */ + public function getParameterUsages(): Collection + { + return $this->parameter_usages; + } + + public function addParameterUsage(AbstractParameter $parameter): self + { + if (!$this->parameter_usages->contains($parameter)) { + $this->parameter_usages->add($parameter); + } + + if ($parameter->getDefinition() !== $this) { + $parameter->setDefinition($this); + } + + return $this; + } + + public function removeParameterUsage(AbstractParameter $parameter): self + { + $this->parameter_usages->removeElement($parameter); + + if ($parameter->getDefinition() === $this) { + $parameter->setDefinition(null); + } + + return $this; + } + + #[Assert\Callback] + public function validateChoices(ExecutionContextInterface $context): void + { + if ($this->choices_contain_invalid_values) { + $context->buildViolation('parameter_definition.validator.choice_not_string') + ->atPath('choices') + ->addViolation(); + } + if ($this->deprecated_choices_contain_invalid_values) { + $context->buildViolation('parameter_definition.validator.choice_not_string') + ->atPath('deprecated_choices') + ->addViolation(); + } + + if (self::INPUT_TYPE_TEXT === $this->input_type + && ([] !== $this->getChoices() || [] !== $this->getDeprecatedChoices())) { + $context->buildViolation('parameter_definition.validator.text_has_choices') + ->atPath('choices') + ->addViolation(); + } + + foreach (['choices' => $this->getChoices(), 'deprecated_choices' => $this->getDeprecatedChoices()] as $path => $choices) { + foreach ($choices as $choice) { + if (mb_strlen($choice) > self::MAX_CHOICE_LENGTH) { + $context->buildViolation('parameter_definition.validator.choice_too_long') + ->setParameter('{{ limit }}', (string) self::MAX_CHOICE_LENGTH) + ->atPath($path) + ->addViolation(); + } + } + } + } + + /** + * @param array $choices + * @return list + */ + private static function canonicalizeChoices(array $choices, bool &$contains_invalid_values = false): array + { + $canonical_choices = []; + $seen_choices = []; + $contains_invalid_values = false; + + foreach ($choices as $choice) { + if (!is_string($choice)) { + $contains_invalid_values = true; + continue; + } + + $choice = trim($choice); + if ('' === $choice) { + continue; + } + $normalized_choice = self::normalize($choice); + if (isset($seen_choices[$normalized_choice])) { + continue; + } + + $seen_choices[$normalized_choice] = true; + $canonical_choices[] = $choice; + } + + return $canonical_choices; + } + + /** + * @param list $choices + */ + private static function findCanonicalInChoices(string $choice, array $choices): ?string + { + $normalized_choice = self::normalize($choice); + foreach ($choices as $canonical_choice) { + if (self::normalize($canonical_choice) === $normalized_choice) { + return $canonical_choice; + } + } + + return null; + } + + private static function normalize(string $value): string + { + return mb_strtolower(trim($value)); + } +} diff --git a/src/Entity/Parameters/ParametersTrait.php b/src/Entity/Parameters/ParametersTrait.php index 2ccaa7639..49538b7b9 100644 --- a/src/Entity/Parameters/ParametersTrait.php +++ b/src/Entity/Parameters/ParametersTrait.php @@ -87,7 +87,9 @@ public function addParameter(AbstractParameter $parameter): self */ public function removeParameter(AbstractParameter $parameter): self { - $this->parameters->removeElement($parameter); + if ($this->parameters->removeElement($parameter)) { + $parameter->setDefinition(null); + } return $this; } diff --git a/src/Entity/Parameters/PartParameter.php b/src/Entity/Parameters/PartParameter.php index 91b51c007..76ac61edb 100644 --- a/src/Entity/Parameters/PartParameter.php +++ b/src/Entity/Parameters/PartParameter.php @@ -52,7 +52,7 @@ /** * @see \App\Tests\Entity\Parameters\PartParameterTest */ -#[UniqueEntity(fields: ['name', 'group', 'element'])] +#[UniqueEntity(fields: ['name', 'group', 'element'], repositoryMethod: 'findActiveForUniqueValidation')] #[ORM\Entity(repositoryClass: ParameterRepository::class)] class PartParameter extends AbstractParameter { diff --git a/src/Entity/UserSystem/PermissionData.php b/src/Entity/UserSystem/PermissionData.php index b7d1ff8f5..337e3c38e 100644 --- a/src/Entity/UserSystem/PermissionData.php +++ b/src/Entity/UserSystem/PermissionData.php @@ -43,7 +43,7 @@ final class PermissionData implements \JsonSerializable /** * The current schema version of the permission data */ - public const CURRENT_SCHEMA_VERSION = 4; + public const CURRENT_SCHEMA_VERSION = 5; /** * Creates a new Permission Data Instance using the given data. diff --git a/src/EventSubscriber/UserSystem/UpgradePermissionsSchemaSubscriber.php b/src/EventSubscriber/UserSystem/UpgradePermissionsSchemaSubscriber.php index d4cf7fe66..498485bd8 100644 --- a/src/EventSubscriber/UserSystem/UpgradePermissionsSchemaSubscriber.php +++ b/src/EventSubscriber/UserSystem/UpgradePermissionsSchemaSubscriber.php @@ -73,6 +73,6 @@ public function onRequest(RequestEvent $event): void public static function getSubscribedEvents(): array { - return [KernelEvents::REQUEST => 'onRequest']; + return [KernelEvents::REQUEST => ['onRequest', 6]]; } } diff --git a/src/Form/AdminPages/BaseEntityAdminForm.php b/src/Form/AdminPages/BaseEntityAdminForm.php index 54cb04069..5491c456b 100644 --- a/src/Form/AdminPages/BaseEntityAdminForm.php +++ b/src/Form/AdminPages/BaseEntityAdminForm.php @@ -56,6 +56,7 @@ public function configureOptions(OptionsResolver $resolver): void parent::configureOptions($resolver); $resolver->setRequired('attachment_class'); $resolver->setRequired('parameter_class'); + $resolver->setAllowedTypes('attachment_class', 'string'); $resolver->setAllowedTypes('parameter_class', ['string', 'null']); $resolver->setDefaults([ @@ -135,26 +136,28 @@ public function buildForm(FormBuilderInterface $builder, array $options): void $this->additionalFormElements($builder, $options, $entity); - //Attachment section - $builder->add('attachments', CollectionType::class, [ - 'entry_type' => AttachmentFormType::class, - 'allow_add' => true, - 'allow_delete' => true, - 'label' => false, - 'reindex_enable' => true, - 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), - 'entry_options' => [ - 'data_class' => $options['attachment_class'], - ], - 'by_reference' => false, - ]); + if ('' !== $options['attachment_class']) { + //Attachment section + $builder->add('attachments', CollectionType::class, [ + 'entry_type' => AttachmentFormType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'label' => false, + 'reindex_enable' => true, + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), + 'entry_options' => [ + 'data_class' => $options['attachment_class'], + ], + 'by_reference' => false, + ]); - $builder->add('master_picture_attachment', MasterPictureAttachmentType::class, [ - 'required' => false, - 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), - 'label' => 'part.edit.master_attachment', - 'entity' => $entity, - ]); + $builder->add('master_picture_attachment', MasterPictureAttachmentType::class, [ + 'required' => false, + 'disabled' => !$this->security->isGranted($is_new ? 'create' : 'edit', $entity), + 'label' => 'part.edit.master_attachment', + 'entity' => $entity, + ]); + } $builder->add('log_comment', TextType::class, [ 'label' => 'edit.log_comment', diff --git a/src/Form/AdminPages/ParameterDefinitionAdminForm.php b/src/Form/AdminPages/ParameterDefinitionAdminForm.php new file mode 100644 index 000000000..a0b6e870b --- /dev/null +++ b/src/Form/AdminPages/ParameterDefinitionAdminForm.php @@ -0,0 +1,84 @@ +getID(); + $disabled = !$this->security->isGranted($is_new ? 'create' : 'edit', $entity); + + $builder + ->add('symbol', TextType::class, [ + 'required' => false, + 'empty_data' => '', + 'label' => 'parameter_definition.symbol', + 'disabled' => $disabled, + ]) + ->add('unit', TextType::class, [ + 'required' => false, + 'empty_data' => '', + 'label' => 'parameter_definition.unit', + 'disabled' => $disabled, + ]) + ->add('input_type', ChoiceType::class, [ + 'label' => 'parameter_definition.input_type', + 'choices' => [ + 'parameter_definition.input_type.text' => ParameterDefinition::INPUT_TYPE_TEXT, + 'parameter_definition.input_type.choice' => ParameterDefinition::INPUT_TYPE_CHOICE, + ], + 'disabled' => $disabled, + ]) + ->add('choices_text', TextareaType::class, [ + 'mapped' => false, + 'data' => $entity->getChoicesText(), + 'required' => false, + 'empty_data' => '', + 'label' => 'parameter_definition.choices', + 'help' => 'parameter_definition.choices.help', + 'attr' => ['rows' => 8], + 'disabled' => $disabled, + ]); + + $builder->addEventListener(FormEvents::SUBMIT, static function (FormEvent $event): void { + $definition = $event->getData(); + if (!$definition instanceof ParameterDefinition) { + return; + } + + if (ParameterDefinition::INPUT_TYPE_CHOICE === $definition->getInputType()) { + $choices_text = $event->getForm()->get('choices_text')->getData(); + $definition->setChoicesText(is_string($choices_text) ? $choices_text : null); + } else { + $definition->setChoices(null); + } + }); + } +} diff --git a/src/Form/Filters/Constraints/ParameterChoiceConstraintType.php b/src/Form/Filters/Constraints/ParameterChoiceConstraintType.php new file mode 100644 index 000000000..3d0464096 --- /dev/null +++ b/src/Form/Filters/Constraints/ParameterChoiceConstraintType.php @@ -0,0 +1,89 @@ +setRequired('parameter_choices'); + $resolver->setAllowedTypes('parameter_choices', 'array'); + $resolver->setDefault('parameter_deprecated_choices', []); + $resolver->setAllowedTypes('parameter_deprecated_choices', 'array'); + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $choices = []; + foreach ($options['parameter_choices'] as $choice) { + $choices[$choice] = $choice; + } + foreach ($options['parameter_deprecated_choices'] as $choice) { + $choices[$this->translator->trans( + 'parameter_definition.choice.deprecated_label', + ['%choice%' => $choice], + )] = $choice; + } + + $builder->add('value', ChoiceType::class, [ + 'choices' => $choices, + 'choice_translation_domain' => false, + 'required' => false, + 'placeholder' => 'selectpicker.nothing_selected', + 'empty_data' => '', + ]); + + $builder->addEventListener(FormEvents::PRE_SET_DATA, static function (FormEvent $event): void { + $constraint = $event->getData(); + if ($constraint instanceof TextConstraint) { + $constraint->setOperator('='); + } + }); + + $builder->addEventListener(FormEvents::PRE_SUBMIT, static function (FormEvent $event): void { + $submitted_data = $event->getData(); + if (is_array($submitted_data)) { + $submitted_data['operator'] = '='; + $event->setData($submitted_data); + } + }); + + $builder->addEventListener(FormEvents::SUBMIT, static function (FormEvent $event): void { + $constraint = $event->getData(); + if ($constraint instanceof TextConstraint) { + $constraint->setOperator('='); + } + }); + } + + public function getParent(): string + { + return TextConstraintType::class; + } +} diff --git a/src/Form/Filters/Constraints/ParameterConstraintType.php b/src/Form/Filters/Constraints/ParameterConstraintType.php index 3c3b396d9..9ee7e94b0 100644 --- a/src/Form/Filters/Constraints/ParameterConstraintType.php +++ b/src/Form/Filters/Constraints/ParameterConstraintType.php @@ -23,16 +23,24 @@ namespace App\Form\Filters\Constraints; use App\DataTables\Filters\Constraints\Part\ParameterConstraint; +use App\Entity\Parameters\ParameterDefinition; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\SearchType; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; +use Symfony\Component\Form\FormInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class ParameterConstraintType extends AbstractType { + public function __construct(private readonly EntityManagerInterface $entity_manager) + { + } + public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ @@ -44,8 +52,16 @@ public function configureOptions(OptionsResolver $resolver): void public function buildForm(FormBuilderInterface $builder, array $options): void { + $builder->add('definition', EntityType::class, [ + 'class' => ParameterDefinition::class, + 'choice_label' => 'name', + 'required' => false, + 'placeholder' => '', + ]); + $builder->add('name', TextType::class, [ 'required' => false, + 'empty_data' => '', ]); $builder->add('unit', SearchType::class, [ @@ -71,12 +87,63 @@ public function buildForm(FormBuilderInterface $builder, array $options): void * arguments. * Ensure that the data is never null, but use an empty ParameterConstraint instead */ - $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) { + $builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void { $data = $event->getData(); if ($data === null) { - $event->setData(new ParameterConstraint()); + $data = new ParameterConstraint(); + $event->setData($data); } + + $this->addValueTextField( + $event->getForm(), + $data instanceof ParameterConstraint ? $data->getDefinition() : null, + ); }); + + $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { + $submitted_data = $event->getData(); + $definition = null; + + if (is_array($submitted_data)) { + $definition_id = filter_var( + $submitted_data['definition'] ?? null, + FILTER_VALIDATE_INT, + ['options' => ['min_range' => 1]], + ); + if (false !== $definition_id) { + $definition = $this->entity_manager->find(ParameterDefinition::class, $definition_id); + } + + if ($definition instanceof ParameterDefinition) { + $submitted_data['name'] = $definition->getName(); + $submitted_data['symbol'] = ''; + $submitted_data['unit'] = ''; + $submitted_data['value'] = [ + 'operator' => '', + 'value1' => '', + 'value2' => '', + ]; + $event->setData($submitted_data); + } + } + + $this->addValueTextField($event->getForm(), $definition); + }); + } + + private function addValueTextField(FormInterface $form, ?ParameterDefinition $definition): void + { + if ($definition instanceof ParameterDefinition + && ParameterDefinition::INPUT_TYPE_CHOICE === $definition->getInputType()) { + $form->add('value_text', ParameterChoiceConstraintType::class, [ + 'parameter_choices' => $definition->getChoices(), + 'parameter_deprecated_choices' => $definition->getDeprecatedChoices(), + ]); + + return; + } + + $form->add('value_text', TextConstraintType::class); } } diff --git a/src/Form/Filters/LogFilterType.php b/src/Form/Filters/LogFilterType.php index 32bac649b..d541e3aeb 100644 --- a/src/Form/Filters/LogFilterType.php +++ b/src/Form/Filters/LogFilterType.php @@ -128,6 +128,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void LogTargetType::PRICEDETAIL => 'pricedetail.label', LogTargetType::MEASUREMENT_UNIT => 'measurement_unit.label', LogTargetType::PARAMETER => 'parameter.label', + LogTargetType::PARAMETER_DEFINITION => 'parameter_definition.label', LogTargetType::LABEL_PROFILE => 'label_profile.label', LogTargetType::PART_ASSOCIATION => 'part_association.label', LogTargetType::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label', diff --git a/src/Form/ParameterType.php b/src/Form/ParameterType.php index cc9542670..4469ff725 100644 --- a/src/Form/ParameterType.php +++ b/src/Form/ParameterType.php @@ -50,31 +50,58 @@ use App\Entity\Parameters\GroupParameter; use App\Entity\Parameters\ManufacturerParameter; use App\Entity\Parameters\PartParameter; +use App\Entity\Parameters\ParameterDefinition; use App\Entity\Parameters\StorageLocationParameter; use App\Entity\Parameters\SupplierParameter; use App\Entity\Parts\MeasurementUnit; use App\Form\Type\ExponentialNumberType; use App\Form\Type\TriStateCheckboxType; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bridge\Doctrine\Form\Type\EntityType; +use Symfony\Bundle\SecurityBundle\Security; use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Event\PreSetDataEvent; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\HiddenType; use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormEvent; +use Symfony\Component\Form\FormError; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\Contracts\Translation\TranslatorInterface; class ParameterType extends AbstractType { + public function __construct( + private readonly EntityManagerInterface $entity_manager, + private readonly Security $security, + private readonly TranslatorInterface $translator, + ) { + } + public function buildForm(FormBuilderInterface $builder, array $options): void { - $builder->add('name', TextType::class, [ + $parameter = $builder->getData(); + $linked_part_parameter = $parameter instanceof PartParameter + && $parameter->getDefinition() instanceof ParameterDefinition; + + $name_options = [ 'label' => false, 'empty_data' => '', 'attr' => [ 'placeholder' => 'parameters.name.placeholder', 'class' => 'form-control-sm', ], - ]); - $builder->add('symbol', TextType::class, [ + ]; + if ($linked_part_parameter) { + $name_options['data'] = $parameter->getEffectiveName(); + } + $builder->add('name', TextType::class, $name_options); + + $symbol_options = [ 'label' => false, 'required' => false, 'empty_data' => '', @@ -83,16 +110,30 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'class' => 'form-control-sm', 'style' => 'max-width: 12ch;', ], - ]); - $builder->add('value_text', TextType::class, [ - 'label' => false, - 'required' => false, - 'empty_data' => '', - 'attr' => [ - 'placeholder' => 'parameters.text.placeholder', - 'class' => 'form-control-sm', - ], - ]); + ]; + if ($linked_part_parameter) { + $symbol_options['data'] = $parameter->getEffectiveSymbol(); + $symbol_options['attr']['readonly'] = true; + } + $builder->add('symbol', TextType::class, $symbol_options); + + $builder->addEventListener( + FormEvents::PRE_SET_DATA, + function (PreSetDataEvent $event): void { + $parameter = $event->getData(); + $definition = $parameter instanceof PartParameter ? $parameter->getDefinition() : null; + $this->addValueTextField( + $event->getForm(), + $parameter instanceof PartParameter + ? $parameter->getEffectiveInputType() + : ParameterDefinition::INPUT_TYPE_TEXT, + $parameter instanceof PartParameter ? $parameter->getEffectiveChoices() : [], + $definition?->findCanonicalDeprecatedChoice( + $parameter instanceof PartParameter ? $parameter->getValueText() : '', + ), + ); + } + ); $builder->add('value_max', ExponentialNumberType::class, [ 'label' => false, @@ -127,7 +168,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'style' => 'max-width: 25ch;', ], ]); - $builder->add('unit', TextType::class, [ + $unit_options = [ 'label' => false, 'required' => false, 'empty_data' => '', @@ -136,7 +177,12 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'class' => 'form-control-sm', 'style' => 'max-width: 8ch;', ], - ]); + ]; + if ($linked_part_parameter) { + $unit_options['data'] = $parameter->getEffectiveUnit(); + $unit_options['attr']['readonly'] = true; + } + $builder->add('unit', TextType::class, $unit_options); $builder->add('group', TextType::class, [ 'label' => false, @@ -147,9 +193,146 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'class' => 'form-control-sm', ], ]); - // Only show the EDA visibility field for part parameters, as it has no function for other entities if ($options['data_class'] === PartParameter::class) { + $builder->add('definition', EntityType::class, [ + 'class' => ParameterDefinition::class, + 'choice_label' => 'name', + 'choice_lazy' => true, + 'label' => false, + 'required' => false, + 'placeholder' => '', + 'attr' => [ + 'class' => 'd-none', + ], + ]); + + $builder->add('new_choice_value', HiddenType::class, [ + 'mapped' => false, + 'required' => false, + 'empty_data' => '', + ]); + + $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void { + $submitted_data = $event->getData(); + $original_parameter = $event->getForm()->getData(); + $definition = null; + $pending_choice = ''; + $current_deprecated_choice = null; + + if (is_array($submitted_data)) { + $definition_id = filter_var( + $submitted_data['definition'] ?? null, + FILTER_VALIDATE_INT, + ['options' => ['min_range' => 1]], + ); + if (false !== $definition_id) { + $definition = $this->entity_manager->find(ParameterDefinition::class, $definition_id); + } + + if ($definition instanceof ParameterDefinition + && ParameterDefinition::INPUT_TYPE_CHOICE === $definition->getInputType()) { + $submitted_value = trim((string) ($submitted_data['value_text'] ?? '')); + $pending_choice = trim((string) ($submitted_data['new_choice_value'] ?? '')); + $canonical_choice = $definition->findCanonicalChoice($submitted_value); + $original_definition = $original_parameter instanceof PartParameter + ? $original_parameter->getDefinition() + : null; + if ($original_definition instanceof ParameterDefinition + && $original_definition->getID() === $definition->getID()) { + $current_deprecated_choice = $definition->findCanonicalDeprecatedChoice( + $original_parameter->getValueText(), + ); + } + $submitted_deprecated_choice = $definition->findCanonicalDeprecatedChoice($submitted_value); + + if (null !== $canonical_choice) { + $submitted_data['value_text'] = $canonical_choice; + $submitted_data['new_choice_value'] = ''; + $pending_choice = ''; + } elseif (null !== $current_deprecated_choice + && $submitted_deprecated_choice === $current_deprecated_choice) { + $submitted_data['value_text'] = $current_deprecated_choice; + $submitted_data['new_choice_value'] = ''; + $pending_choice = ''; + } elseif ('' === $submitted_value) { + $submitted_data['value_text'] = ''; + $submitted_data['new_choice_value'] = ''; + $pending_choice = ''; + } elseif ('' !== $pending_choice) { + $submitted_data['value_text'] = $submitted_value; + if (mb_strtolower($pending_choice) === mb_strtolower($submitted_value)) { + $pending_choice = $submitted_value; + $submitted_data['new_choice_value'] = $pending_choice; + } else { + $submitted_data['new_choice_value'] = ''; + $pending_choice = ''; + } + } + + $event->setData($submitted_data); + } + } + + $choices = $definition?->getChoices() ?? []; + if (null !== $current_deprecated_choice && !in_array($current_deprecated_choice, $choices, true)) { + $choices[] = $current_deprecated_choice; + } + if ('' !== $pending_choice && !in_array($pending_choice, $choices, true)) { + $choices[] = $pending_choice; + } + $this->addValueTextField( + $event->getForm(), + $definition?->getInputType() ?? ParameterDefinition::INPUT_TYPE_TEXT, + $choices, + $current_deprecated_choice, + ); + }); + + $builder->addEventListener(FormEvents::SUBMIT, function (FormEvent $event): void { + $parameter = $event->getData(); + if (!$parameter instanceof PartParameter) { + return; + } + + $parameter->clearPendingDefinitionChoice(); + $definition = $parameter->getDefinition(); + $pending_choice = trim((string) $event->getForm()->get('new_choice_value')->getData()); + + if ('' !== $pending_choice) { + $error = null; + $visible_value = trim($parameter->getValueText()); + $canonical_choice = $definition?->findCanonicalChoice($visible_value); + + if (!$definition instanceof ParameterDefinition + || ParameterDefinition::INPUT_TYPE_CHOICE !== $definition->getInputType()) { + $error = 'parameter.validator.new_choice_requires_choice_definition'; + } elseif (null !== $canonical_choice) { + $parameter->setValueText($canonical_choice); + } elseif ($pending_choice !== $visible_value) { + $error = 'parameter.validator.new_choice_value_mismatch'; + } elseif (mb_strlen($pending_choice) > ParameterDefinition::MAX_CHOICE_LENGTH) { + $error = 'parameter_definition.validator.choice_too_long'; + } elseif (!$this->security->isGranted('edit', $definition)) { + $error = 'parameter.validator.new_choice_forbidden'; + } else { + $parameter->requestPendingDefinitionChoice($pending_choice); + } + + if (null !== $error) { + $event->getForm()->get('value_text')->addError(new FormError( + $error, + $error, + ['{{ limit }}' => (string) ParameterDefinition::MAX_CHOICE_LENGTH], + )); + } + } + + if ($definition instanceof ParameterDefinition) { + $parameter->refreshSnapshotFromDefinition(); + } + }); + $builder->add('eda_visibility', TriStateCheckboxType::class, [ 'label' => false, 'required' => false, @@ -159,6 +342,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'label' => false, 'required' => false, ]); + } } @@ -194,4 +378,64 @@ public function configureOptions(OptionsResolver $resolver): void 'data_class' => AbstractParameter::class, ]); } + + /** @param list $choices */ + private function addValueTextField( + FormInterface $form, + string $input_type, + array $choices, + ?string $current_deprecated_choice = null, + ): void + { + $can_add_choice = $this->security->isGranted('edit', ParameterDefinition::class) ? 'true' : 'false'; + + if (ParameterDefinition::INPUT_TYPE_CHOICE === $input_type) { + if (null !== $current_deprecated_choice && !in_array($current_deprecated_choice, $choices, true)) { + $choices[] = $current_deprecated_choice; + } + $choice_map = []; + foreach ($choices as $choice) { + $label = $choice === $current_deprecated_choice + ? $this->translator->trans( + 'parameter_definition.choice.deprecated_label', + ['%choice%' => $choice], + ) + : $choice; + $choice_map[$label] = $choice; + } + + $form->add('value_text', ChoiceType::class, [ + 'label' => false, + 'required' => false, + 'placeholder' => '', + 'empty_data' => '', + 'choices' => $choice_map, + 'choice_translation_domain' => false, + 'choice_attr' => static fn (string $choice): array => $choice === $current_deprecated_choice + ? ['data-deprecated-choice' => 'true'] + : [], + 'translation_domain' => 'validators', + 'attr' => [ + 'class' => 'form-select-sm', + // The parameter autocomplete controller owns this TomSelect instance. Defining an empty + // controller prevents the global choice_widget theme from initializing elements--select first. + 'data-controller' => '', + 'data-can-add-choice' => $can_add_choice, + ], + ]); + + return; + } + + $form->add('value_text', TextType::class, [ + 'label' => false, + 'required' => false, + 'empty_data' => '', + 'attr' => [ + 'placeholder' => 'parameters.text.placeholder', + 'class' => 'form-control-sm', + 'data-can-add-choice' => $can_add_choice, + ], + ]); + } } diff --git a/src/Repository/ParameterDefinitionRepository.php b/src/Repository/ParameterDefinitionRepository.php new file mode 100644 index 000000000..8779ff9fc --- /dev/null +++ b/src/Repository/ParameterDefinitionRepository.php @@ -0,0 +1,65 @@ + + */ +class ParameterDefinitionRepository extends NamedDBElementRepository +{ + /** + * @return list|null, + * deprecated_choices: list|null + * }> + */ + public function autocompleteForParameterEditor(string $name, int $max_results = 50): array + { + /** @var list|null, + * deprecated_choices: list|null + * }> $result + */ + $result = $this->createQueryBuilder('definition') + ->select('definition.id AS definition_id') + ->addSelect('definition.name AS name') + ->addSelect('definition.symbol AS symbol') + ->addSelect('definition.unit AS unit') + ->addSelect('definition.input_type AS input_type') + ->addSelect('definition.choices AS choices') + ->addSelect('definition.deprecated_choices AS deprecated_choices') + ->where('ILIKE(definition.name, :name) = TRUE') + ->setParameter('name', '%'.$name.'%') + ->orderBy('definition.name', 'ASC') + ->setMaxResults($max_results) + ->getQuery() + ->getArrayResult(); + + return $result; + } +} diff --git a/src/Repository/ParameterRepository.php b/src/Repository/ParameterRepository.php index 6c6c867d6..dcb545239 100644 --- a/src/Repository/ParameterRepository.php +++ b/src/Repository/ParameterRepository.php @@ -23,6 +23,9 @@ namespace App\Repository; use App\Entity\Parameters\AbstractParameter; +use App\Entity\Parameters\ParameterDefinition; +use App\Entity\Parameters\PartParameter; +use App\Entity\Parts\Part; /** * @template TEntityClass of AbstractParameter @@ -30,6 +33,39 @@ */ class ParameterRepository extends DBElementRepository { + /** + * UniqueEntity runs before Doctrine flushes orphan removals. Ignore a persisted PartParameter only when it has + * already been removed from its owning Part's active collection; active database matches remain conflicts. + * + * @param array $criteria + * @return list + */ + public function findActiveForUniqueValidation(array $criteria): array + { + return array_values(array_filter( + $this->findBy($criteria), + static function (AbstractParameter $parameter): bool { + if (!$parameter instanceof PartParameter) { + return true; + } + + $part = $parameter->getElement(); + + return !$part instanceof Part || $part->getParameters()->contains($parameter); + }, + )); + } + + public function countByDefinition(ParameterDefinition $definition): int + { + return (int) $this->createQueryBuilder('parameter') + ->select('COUNT(parameter.id)') + ->where('parameter.definition = :definition') + ->setParameter('definition', $definition) + ->getQuery() + ->getSingleScalarResult(); + } + /** * Find parameters using a parameter name * @param string $name The name to search for diff --git a/src/Security/Voter/StructureVoter.php b/src/Security/Voter/StructureVoter.php index 16d38e058..ab03173c6 100644 --- a/src/Security/Voter/StructureVoter.php +++ b/src/Security/Voter/StructureVoter.php @@ -24,6 +24,7 @@ use App\Entity\Attachments\AttachmentType; use App\Entity\Parts\PartCustomState; +use App\Entity\Parameters\ParameterDefinition; use App\Entity\ProjectSystem\Project; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; @@ -40,7 +41,7 @@ use function is_object; /** - * @phpstan-extends Voter + * @phpstan-extends Voter */ final class StructureVoter extends Voter { @@ -55,6 +56,7 @@ final class StructureVoter extends Voter Currency::class => 'currencies', MeasurementUnit::class => 'measurement_units', PartCustomState::class => 'part_custom_states', + ParameterDefinition::class => 'parameter_definitions', ]; public function __construct(private readonly VoterHelper $helper) diff --git a/src/Services/ElementTypes.php b/src/Services/ElementTypes.php index 6ce8f8514..f1a9f0554 100644 --- a/src/Services/ElementTypes.php +++ b/src/Services/ElementTypes.php @@ -29,6 +29,7 @@ use App\Entity\InfoProviderSystem\BulkInfoProviderImportJobPart; use App\Entity\LabelSystem\LabelProfile; use App\Entity\Parameters\AbstractParameter; +use App\Entity\Parameters\ParameterDefinition; use App\Entity\Parts\Category; use App\Entity\Parts\Footprint; use App\Entity\Parts\Manufacturer; @@ -70,6 +71,7 @@ enum ElementTypes: string implements TranslatableInterface case GROUP = "group"; case USER = "user"; case PARAMETER = "parameter"; + case PARAMETER_DEFINITION = "parameter_definition"; case LABEL_PROFILE = "label_profile"; case PART_ASSOCIATION = "part_association"; case BULK_INFO_PROVIDER_IMPORT_JOB = "bulk_info_provider_import_job"; @@ -96,6 +98,7 @@ enum ElementTypes: string implements TranslatableInterface Group::class => self::GROUP, User::class => self::USER, AbstractParameter::class => self::PARAMETER, + ParameterDefinition::class => self::PARAMETER_DEFINITION, LabelProfile::class => self::LABEL_PROFILE, PartAssociation::class => self::PART_ASSOCIATION, BulkInfoProviderImportJob::class => self::BULK_INFO_PROVIDER_IMPORT_JOB, @@ -127,6 +130,7 @@ public function getDefaultLabelKey(): string self::GROUP => 'group.label', self::USER => 'user.label', self::PARAMETER => 'parameter.label', + self::PARAMETER_DEFINITION => 'parameter_definition.label', self::LABEL_PROFILE => 'label_profile.label', self::PART_ASSOCIATION => 'part_association.label', self::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.label', @@ -156,6 +160,7 @@ public function getDefaultPluralLabelKey(): string self::GROUP => 'group.labelp', self::USER => 'user.labelp', self::PARAMETER => 'parameter.labelp', + self::PARAMETER_DEFINITION => 'parameter_definition.labelp', self::LABEL_PROFILE => 'label_profile.labelp', self::PART_ASSOCIATION => 'part_association.labelp', self::BULK_INFO_PROVIDER_IMPORT_JOB => 'bulk_info_provider_import_job.labelp', diff --git a/src/Services/EntityURLGenerator.php b/src/Services/EntityURLGenerator.php index 91e271cc0..cb84f3b96 100644 --- a/src/Services/EntityURLGenerator.php +++ b/src/Services/EntityURLGenerator.php @@ -27,6 +27,7 @@ use App\Entity\Attachments\PartAttachment; use App\Entity\Base\AbstractDBElement; use App\Entity\Parameters\PartParameter; +use App\Entity\Parameters\ParameterDefinition; use App\Entity\Parts\PartCustomState; use App\Entity\ProjectSystem\Project; use App\Entity\LabelSystem\LabelProfile; @@ -109,6 +110,7 @@ public function timeTravelURL(AbstractDBElement $entity, \DateTimeInterface $dat Group::class => 'group_edit', LabelProfile::class => 'label_profile_edit', PartCustomState::class => 'part_custom_state_edit', + ParameterDefinition::class => 'parameter_definition_edit', ]; try { @@ -216,6 +218,7 @@ public function infoURL(AbstractDBElement $entity): string Group::class => 'group_edit', LabelProfile::class => 'label_profile_edit', PartCustomState::class => 'part_custom_state_edit', + ParameterDefinition::class => 'parameter_definition_edit', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); @@ -247,6 +250,7 @@ public function editURL(AbstractDBElement $entity): string Group::class => 'group_edit', LabelProfile::class => 'label_profile_edit', PartCustomState::class => 'part_custom_state_edit', + ParameterDefinition::class => 'parameter_definition_edit', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); @@ -279,6 +283,7 @@ public function createURL(AbstractDBElement|string $entity): string Group::class => 'group_new', LabelProfile::class => 'label_profile_new', PartCustomState::class => 'part_custom_state_new', + ParameterDefinition::class => 'parameter_definition_new', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity)); @@ -311,6 +316,7 @@ public function cloneURL(AbstractDBElement $entity): string Group::class => 'group_clone', LabelProfile::class => 'label_profile_clone', PartCustomState::class => 'part_custom_state_clone', + ParameterDefinition::class => 'parameter_definition_clone', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); @@ -357,6 +363,7 @@ public function deleteURL(AbstractDBElement $entity): string Group::class => 'group_delete', LabelProfile::class => 'label_profile_delete', PartCustomState::class => 'part_custom_state_delete', + ParameterDefinition::class => 'parameter_definition_delete', ]; return $this->urlGenerator->generate($this->mapToController($map, $entity), ['id' => $entity->getID()]); diff --git a/src/Services/LogSystem/TimeTravel.php b/src/Services/LogSystem/TimeTravel.php index 751e999e0..c27e8b84e 100644 --- a/src/Services/LogSystem/TimeTravel.php +++ b/src/Services/LogSystem/TimeTravel.php @@ -30,6 +30,8 @@ use App\Entity\LogSystem\AbstractLogEntry; use App\Entity\LogSystem\CollectionElementDeleted; use App\Entity\LogSystem\ElementEditedLogEntry; +use App\Entity\Parameters\AbstractParameter; +use App\Entity\Parameters\ParameterDefinition; use App\Repository\LogEntryRepository; use Brick\Math\BigDecimal; use DateTime; @@ -134,6 +136,9 @@ public function revertEntityToTimestamp(AbstractDBElement $element, \DateTimeInt if ( ($element instanceof AbstractStructuralDBElement && ('parts' === $field || 'children' === $field)) || ($element instanceof AttachmentType && 'attachments' === $field) + //applyEntry() restores the parameter's historical definition reference. Only skip recursively + //reverting the shared global definition object itself; historical metadata comes from snapshots. + || ($element instanceof AbstractParameter && 'definition' === $field) ) { continue; } @@ -238,6 +243,19 @@ public function applyEntry(AbstractDBElement $element, TimeTravelInterface $logE $this->setField($element, $field, $data); } if ($metadata->hasAssociation($field)) { + if ($element instanceof AbstractParameter && 'definition' === $field) { + if (null === $data) { + $element->restoreDefinitionReference(null); + } elseif (is_array($data) && isset($data['@id'])) { + $definition = $this->em->find(ParameterDefinition::class, $data['@id']); + $element->restoreDefinitionReference( + $definition instanceof ParameterDefinition ? $definition : null + ); + } + + continue; + } + $mapping = $metadata->getAssociationMapping($field); $target_class = $mapping['targetEntity']; //Try to extract the old ID: diff --git a/src/Services/Parameters/PendingParameterChoiceApplier.php b/src/Services/Parameters/PendingParameterChoiceApplier.php new file mode 100644 index 000000000..caaa6434a --- /dev/null +++ b/src/Services/Parameters/PendingParameterChoiceApplier.php @@ -0,0 +1,66 @@ +getParameters() as $parameter) { + if (!$parameter instanceof PartParameter) { + continue; + } + + $pending_choice = $parameter->getPendingDefinitionChoice(); + if (null === $pending_choice) { + continue; + } + + $definition = $parameter->getDefinition(); + $pending_choice = trim($pending_choice); + + if (!$definition instanceof ParameterDefinition + || ParameterDefinition::INPUT_TYPE_CHOICE !== $definition->getInputType()) { + throw new LogicException('A pending choice requires a linked Choice parameter definition.'); + } + if ('' === $pending_choice || mb_strlen($pending_choice) > ParameterDefinition::MAX_CHOICE_LENGTH) { + throw new LogicException('The pending parameter choice is invalid.'); + } + if ($pending_choice !== trim($parameter->getValueText())) { + throw new LogicException('The pending choice does not match the parameter value.'); + } + if (!$this->security->isGranted('edit', $definition)) { + throw new AccessDeniedException('Editing this parameter definition is not allowed.'); + } + + $canonical_choice = $definition->addChoice($pending_choice); + $parameter + ->setValueText($canonical_choice) + ->clearPendingDefinitionChoice(); + } + } +} diff --git a/src/Services/Trees/ToolsTreeBuilder.php b/src/Services/Trees/ToolsTreeBuilder.php index 2b7639803..93180be25 100644 --- a/src/Services/Trees/ToolsTreeBuilder.php +++ b/src/Services/Trees/ToolsTreeBuilder.php @@ -32,6 +32,7 @@ use App\Entity\Parts\PartCustomState; use App\Entity\Parts\StorageLocation; use App\Entity\Parts\Supplier; +use App\Entity\Parameters\ParameterDefinition; use App\Entity\PriceInformations\Currency; use App\Entity\ProjectSystem\Project; use App\Entity\UserSystem\Group; @@ -237,6 +238,12 @@ protected function getEditNodes(): array $this->urlGenerator->generate('measurement_unit_new') ))->setIcon('fa-fw fa-treeview fa-solid fa-balance-scale'); } + if ($this->security->isGranted('read', new ParameterDefinition())) { + $nodes[] = (new TreeViewNode( + $this->elementTypeNameGenerator->typeLabelPlural(ParameterDefinition::class), + $this->urlGenerator->generate('parameter_definition_new') + ))->setIcon('fa-fw fa-treeview fa-solid fa-list-check'); + } if ($this->security->isGranted('read', new LabelProfile())) { $nodes[] = (new TreeViewNode( $this->elementTypeNameGenerator->typeLabelPlural(LabelProfile::class), diff --git a/src/Services/UserSystem/PermissionPresetsHelper.php b/src/Services/UserSystem/PermissionPresetsHelper.php index 378b27a1d..6d8ed2a96 100644 --- a/src/Services/UserSystem/PermissionPresetsHelper.php +++ b/src/Services/UserSystem/PermissionPresetsHelper.php @@ -108,6 +108,7 @@ private function admin(HasPermissionsInterface $perm_holder): void $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'part_custom_states', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'suppliers', PermissionData::ALLOW); $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'projects', PermissionData::ALLOW); + $this->permissionResolver->setAllOperationsOfPermission($perm_holder, 'parameter_definitions', PermissionData::ALLOW); //Allow to change system settings $this->permissionResolver->setPermission($perm_holder, 'config', 'change_system_settings', PermissionData::ALLOW); diff --git a/src/Services/UserSystem/PermissionSchemaUpdater.php b/src/Services/UserSystem/PermissionSchemaUpdater.php index fd85ee7ca..8da515a5d 100644 --- a/src/Services/UserSystem/PermissionSchemaUpdater.php +++ b/src/Services/UserSystem/PermissionSchemaUpdater.php @@ -173,4 +173,24 @@ private function upgradeSchemaToVersion4(HasPermissionsInterface $holder): void $permissions->setPermissionValue('parts_stock', 'stocktake', $new_value); } } + + private function upgradeSchemaToVersion5(HasPermissionsInterface $holder): void //@phpstan-ignore-line This is called via reflection + { + $permissions = $holder->getPermissions(); + + if (!$permissions->isAnyOperationOfPermissionSet('parameter_definitions')) { + // Definitions are required for displaying and using parameters, but managing + // the global library remains restricted to existing system administrators. + $permissions->setPermissionValue( + 'parameter_definitions', + 'read', + $permissions->getPermissionValue('parts', 'read') + ); + + $management_value = $permissions->getPermissionValue('config', 'change_system_settings'); + foreach (['edit', 'create', 'delete', 'show_history', 'revert_element', 'import'] as $operation) { + $permissions->setPermissionValue('parameter_definitions', $operation, $management_value); + } + } + } } diff --git a/templates/admin/base_admin.html.twig b/templates/admin/base_admin.html.twig index f19f4c440..6135d7da6 100644 --- a/templates/admin/base_admin.html.twig +++ b/templates/admin/base_admin.html.twig @@ -83,9 +83,11 @@ {% trans %}admin.common{% endtrans %} {% block additional_pills %}{% endblock %} - + {% if form.attachments is defined %} + + {% endif %} {% if entity.parameters is defined and showParameters == true %}
{% block additional_panes %}{% endblock %} -
- {% include "admin/_attachments.html.twig" %} - {% block master_picture_block %} - {{ form_row(form.master_picture_attachment) }} - {% endblock %} -
+ {% if form.attachments is defined %} +
+ {% include "admin/_attachments.html.twig" %} + {% block master_picture_block %} + {{ form_row(form.master_picture_attachment) }} + {% endblock %} +
+ {% endif %} {% if entity.parameters is defined %}
diff --git a/templates/admin/parameter_definition_admin.html.twig b/templates/admin/parameter_definition_admin.html.twig new file mode 100644 index 000000000..128ce52f2 --- /dev/null +++ b/templates/admin/parameter_definition_admin.html.twig @@ -0,0 +1,35 @@ +{% extends "admin/base_admin.html.twig" %} + +{% block card_title %} + {{ type_label_p(entity) }} +{% endblock %} + +{% block edit_title %} + {% trans %}parameter_definition.edit{% endtrans %}: {{ entity.name }} +{% endblock %} + +{% block new_title %} + {% trans %}parameter_definition.new{% endtrans %} +{% endblock %} + +{% block preview_picture %}{% endblock %} + +{% block additional_controls %} + {{ form_row(form.symbol) }} + {{ form_row(form.unit) }} + {{ form_row(form.input_type) }} + {{ form_row(form.choices_text) }} + {% if entity.deprecatedChoices is not empty %} +
+ + +
{% trans %}parameter_definition.deprecated_choices.help{% endtrans %}
+
+ {% endif %} +{% endblock %} + +{% block comment %}{% endblock %} diff --git a/templates/form/filter_types_layout.html.twig b/templates/form/filter_types_layout.html.twig index 89e8638b5..a47126e1f 100644 --- a/templates/form/filter_types_layout.html.twig +++ b/templates/form/filter_types_layout.html.twig @@ -54,12 +54,56 @@ {% block parameter_constraint_widget %} {% import 'components/collection_type.macro.html.twig' as collection %} - - {{ form_widget(form.name, {"attr": {"data-pages--parameters-autocomplete-target": "name"}}) }} - {{ form_widget(form.symbol, {"attr": {"data-pages--parameters-autocomplete-target": "symbol", "data-pages--latex-preview-target": "input"}}) }} - {{ form_widget(form.value) }} - {{ form_widget(form.unit, {"attr": {"data-pages--parameters-autocomplete-target": "unit", "data-pages--latex-preview-target": "input"}}) }} - {{ form_widget(form.value_text) }} + {% set definition = form.definition.vars.data %} + {% set initial_input_type = definition ? definition.inputType : 'text' %} + + + {{ form_widget(form.name, {"attr": {"data-filters--parameter-constraint-target": "name"}}) }} + {{ form_widget(form.definition, {"attr": { + "class": "d-none", + "data-controller": "", + "data-filters--parameter-constraint-target": "definition" + }}) }} + {{ form_errors(form.definition) }} + + + {{ form_widget(form.symbol, {"attr": { + "data-filters--parameter-constraint-target": "symbol", + "data-pages--latex-preview-target": "input" + }}) }} + + + {{ form_widget(form.value) }} + + {{ form_widget(form.unit, {"attr": { + "data-filters--parameter-constraint-target": "unit", + "data-pages--latex-preview-target": "input" + }}) }} + + + +
+ {{ form_widget(form.value_text.operator, {"attr": { + "class": initial_input_type == 'choice' ? 'd-none' : '', + "data-controller": "", + "data-filters--parameter-constraint-target": "valueOperator" + }}) }} + {{ form_widget(form.value_text.value, {"attr": { + "data-controller": "", + "data-filters--parameter-constraint-target": "valueText" + }}) }} +
+ {{ form_errors(form.value_text.operator) }} + {{ form_errors(form.value_text.value) }} +