From 58e1409e56740599699fb06ba7efa023ef5e520e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Cailly?= Date: Wed, 7 May 2025 09:15:42 +0200 Subject: [PATCH 1/4] feat(forms): Validation conditions for question answers --- .phpstan-baseline.php | 20 +- .../components/form/_form-editor.scss | 2 +- .../update_10.0.x_to_11.0.0/form.php | 22 + install/mysql/glpi-empty.sql | 2 + ...er.js => BaseConditionEditorController.js} | 47 +- .../ConditionValidationEditorController.js | 47 ++ .../ConditionVisibilityEditorController.js | 47 ++ js/modules/Forms/EditorController.js | 112 +++- .../AnswersHandler/AnswersHandlerTest.php | 336 ++++++++---- .../Form/Condition/EditorController.php | 73 ++- .../Form/AnswersHandler/AnswersHandler.php | 34 ++ src/Glpi/Form/Condition/ConditionData.php | 12 + .../Form/Condition/ConditionableTrait.php | 10 +- .../ConditionableValidationTrait.php | 79 +++ src/Glpi/Form/Condition/EditorManager.php | 10 + src/Glpi/Form/Condition/Engine.php | 49 +- .../Form/Condition/EngineValidationOutput.php | 79 +++ .../Form/Condition/ValidationStrategy.php | 83 +++ src/Glpi/Form/Condition/ValueOperator.php | 45 ++ src/Glpi/Form/Question.php | 11 + .../conditional_validation_dropdown.html.twig | 91 ++++ .../conditional_validation_editor.html.twig | 155 ++++++ .../conditional_visibility_dropdown.html.twig | 5 +- ...ibility_conditions_configuration.html.twig | 4 +- .../pages/admin/form/form_comment.html.twig | 2 + .../pages/admin/form/form_question.html.twig | 18 + .../pages/admin/form/form_section.html.twig | 2 + .../cypress/e2e/form/editor/validations.cy.js | 486 ++++++++++++++++++ tests/src/FormBuilder.php | 23 + tests/src/FormTesterTrait.php | 44 ++ 30 files changed, 1794 insertions(+), 156 deletions(-) rename js/modules/Forms/{ConditionEditorController.js => BaseConditionEditorController.js} (92%) create mode 100644 js/modules/Forms/ConditionValidationEditorController.js create mode 100644 js/modules/Forms/ConditionVisibilityEditorController.js create mode 100644 src/Glpi/Form/Condition/ConditionableValidationTrait.php create mode 100644 src/Glpi/Form/Condition/EngineValidationOutput.php create mode 100644 src/Glpi/Form/Condition/ValidationStrategy.php create mode 100644 templates/pages/admin/form/conditional_validation_dropdown.html.twig create mode 100644 templates/pages/admin/form/conditional_validation_editor.html.twig create mode 100644 tests/cypress/e2e/form/editor/validations.cy.js diff --git a/.phpstan-baseline.php b/.phpstan-baseline.php index 2a35481e0a7..19897943479 100644 --- a/.phpstan-baseline.php +++ b/.phpstan-baseline.php @@ -4831,12 +4831,6 @@ 'count' => 1, 'path' => __DIR__ . '/src/Glpi/Form/AnswersSet.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Function json_decode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_decode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#', - 'identifier' => 'theCodingMachineSafe.function', - 'count' => 1, - 'path' => __DIR__ . '/src/Glpi/Form/Comment.php', -]; $ignoreErrors[] = [ 'message' => '#^Function json_encode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_encode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#', 'identifier' => 'theCodingMachineSafe.function', @@ -4912,7 +4906,7 @@ $ignoreErrors[] = [ 'message' => '#^Function json_decode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_decode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#', 'identifier' => 'theCodingMachineSafe.function', - 'count' => 2, + 'count' => 1, 'path' => __DIR__ . '/src/Glpi/Form/Destination/FormDestination.php', ]; $ignoreErrors[] = [ @@ -4948,7 +4942,7 @@ $ignoreErrors[] = [ 'message' => '#^Function json_decode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_decode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#', 'identifier' => 'theCodingMachineSafe.function', - 'count' => 3, + 'count' => 2, 'path' => __DIR__ . '/src/Glpi/Form/Form.php', ]; $ignoreErrors[] = [ @@ -4972,13 +4966,13 @@ $ignoreErrors[] = [ 'message' => '#^Function json_decode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_decode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#', 'identifier' => 'theCodingMachineSafe.function', - 'count' => 4, + 'count' => 3, 'path' => __DIR__ . '/src/Glpi/Form/Question.php', ]; $ignoreErrors[] = [ 'message' => '#^Function json_encode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_encode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#', 'identifier' => 'theCodingMachineSafe.function', - 'count' => 3, + 'count' => 5, 'path' => __DIR__ . '/src/Glpi/Form/Question.php', ]; $ignoreErrors[] = [ @@ -5053,12 +5047,6 @@ 'count' => 4, 'path' => __DIR__ . '/src/Glpi/Form/QuestionType/QuestionTypesManager.php', ]; -$ignoreErrors[] = [ - 'message' => '#^Function json_decode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_decode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#', - 'identifier' => 'theCodingMachineSafe.function', - 'count' => 1, - 'path' => __DIR__ . '/src/Glpi/Form/Section.php', -]; $ignoreErrors[] = [ 'message' => '#^Function json_encode is unsafe to use\\. It can return FALSE instead of throwing an exception\\. Please add \'use function Safe\\\\json_encode;\' at the beginning of the file to use the variant provided by the \'thecodingmachine/safe\' library\\.$#', 'identifier' => 'theCodingMachineSafe.function', diff --git a/css/includes/components/form/_form-editor.scss b/css/includes/components/form/_form-editor.scss index 2b96dce4f3c..3ea87ca1ae1 100644 --- a/css/includes/components/form/_form-editor.scss +++ b/css/includes/components/form/_form-editor.scss @@ -394,7 +394,7 @@ input.value-selector { } } -.visibility-dropdown-card { +.visibility-dropdown-card, .validation-dropdown-card { min-width: 700px; width: fit-content; diff --git a/install/migrations/update_10.0.x_to_11.0.0/form.php b/install/migrations/update_10.0.x_to_11.0.0/form.php index 63ad7e2c806..f17cc3f751a 100644 --- a/install/migrations/update_10.0.x_to_11.0.0/form.php +++ b/install/migrations/update_10.0.x_to_11.0.0/form.php @@ -355,6 +355,28 @@ ); } +if (!$DB->fieldExists('glpi_forms_questions', 'validation_strategy')) { + $migration->addField( + 'glpi_forms_questions', + 'validation_strategy', + "varchar(30) NOT NULL DEFAULT ''", + [ + 'after' => 'conditions', + ] + ); +} + +if (!$DB->fieldExists('glpi_forms_questions', 'validation_conditions')) { + $migration->addField( + 'glpi_forms_questions', + 'validation_conditions', + "JSON NOT NULL", + [ + 'after' => 'validation_strategy', + ] + ); +} + // Add rights for the forms object $migration->addRight("form", ALLSTANDARDRIGHT, ['config' => UPDATE]); diff --git a/install/mysql/glpi-empty.sql b/install/mysql/glpi-empty.sql index d8563397f23..ef317fefc9c 100644 --- a/install/mysql/glpi-empty.sql +++ b/install/mysql/glpi-empty.sql @@ -9555,6 +9555,8 @@ CREATE TABLE `glpi_forms_questions` ( `extra_data` text COMMENT 'JSON - Extra configuration field(s) depending on the questions type', `visibility_strategy` varchar(30) NOT NULL DEFAULT '', `conditions` JSON NOT NULL, + `validation_strategy` varchar(30) NOT NULL DEFAULT '', + `validation_conditions` JSON NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uuid` (`uuid`), KEY `name` (`name`), diff --git a/js/modules/Forms/ConditionEditorController.js b/js/modules/Forms/BaseConditionEditorController.js similarity index 92% rename from js/modules/Forms/ConditionEditorController.js rename to js/modules/Forms/BaseConditionEditorController.js index 80974055d98..93edb15167d 100644 --- a/js/modules/Forms/ConditionEditorController.js +++ b/js/modules/Forms/BaseConditionEditorController.js @@ -30,11 +30,9 @@ * --------------------------------------------------------------------- */ - -export class GlpiFormConditionEditorController -{ +export class BaseConditionEditorController { /** - * Target containerthat will display the condition editor + * Target container that will display the condition editor * @type {HTMLElement} */ #container; @@ -63,8 +61,10 @@ export class GlpiFormConditionEditorController /** @type {?string} */ #item_type; - constructor(container, item_uuid, item_type, forms_sections, form_questions, form_comments) - { + /** @type {string} */ + #editorEndpoint; + + constructor(container, item_uuid, item_type, forms_sections, form_questions, form_comments, editorEndpoint) { this.#container = container; if (this.#container.dataset.glpiConditionsEditorContainer === undefined) { console.error(this.#container); // Help debugging by printing the node. @@ -75,6 +75,9 @@ export class GlpiFormConditionEditorController this.#item_uuid = item_uuid; this.#item_type = item_type; + // Set the editor endpoint URL + this.#editorEndpoint = editorEndpoint; + // Load form sections this.#form_sections = forms_sections; @@ -94,8 +97,7 @@ export class GlpiFormConditionEditorController } } - async renderEditor() - { + async renderEditor() { const data = this.#computeData(); await this.#doRenderEditor(data); } @@ -104,8 +106,7 @@ export class GlpiFormConditionEditorController * In a dynamic environement such as the form editor, it might be necessary * to redefine the known list of available sections. */ - setFormSections(form_sections) - { + setFormSections(form_sections) { this.#form_sections = form_sections; } @@ -113,8 +114,7 @@ export class GlpiFormConditionEditorController * In a dynamic environement such as the form editor, it might be necessary * to redefine the known list of available questions. */ - setFormQuestions(form_questions) - { + setFormQuestions(form_questions) { this.#form_questions = form_questions; } @@ -122,14 +122,12 @@ export class GlpiFormConditionEditorController * In a dynamic environement such as the form editor, it might be necessary * to redefine the known list of available comments. */ - setFormComments(form_comments) - { + setFormComments(form_comments) { this.#form_comments = form_comments; } - async #doRenderEditor(data) - { - const url = `${CFG_GLPI.root_doc}/Form/Condition/Editor`; + async #doRenderEditor(data) { + const url = this.#editorEndpoint; const content = await $.post(url, { form_data: data, }); @@ -138,8 +136,7 @@ export class GlpiFormConditionEditorController $(this.#container.querySelector('[data-glpi-conditions-editor]')).html(content); } - #initEventHandlers() - { + #initEventHandlers() { // Handle add and delete conditions this.#container.addEventListener('click', (e) => { const target = e.target; @@ -195,15 +192,13 @@ export class GlpiFormConditionEditorController } } - async #addNewEmptyCondition() - { + async #addNewEmptyCondition() { const data = this.#computeData(); data.conditions.push({'item': ''}); await this.#doRenderEditor(data); } - async #deleteCondition(condition_index) - { + async #deleteCondition(condition_index) { const data = this.#computeData(); data.conditions = data.conditions.filter((_condition, index) => { return index != condition_index; @@ -211,8 +206,7 @@ export class GlpiFormConditionEditorController await this.#doRenderEditor(data); } - #computeData() - { + #computeData() { return { sections: this.#form_sections, questions: this.#form_questions, @@ -223,8 +217,7 @@ export class GlpiFormConditionEditorController }; } - #computeDefinedConditions() - { + #computeDefinedConditions() { const conditions_data = []; const conditions = this.#container.querySelectorAll( '[data-glpi-conditions-editor-condition]' diff --git a/js/modules/Forms/ConditionValidationEditorController.js b/js/modules/Forms/ConditionValidationEditorController.js new file mode 100644 index 00000000000..05d098a40e3 --- /dev/null +++ b/js/modules/Forms/ConditionValidationEditorController.js @@ -0,0 +1,47 @@ +/** + * --------------------------------------------------------------------- + * + * GLPI - Gestionnaire Libre de Parc Informatique + * + * http://glpi-project.org + * + * @copyright 2015-2025 Teclib' and contributors. + * @licence https://www.gnu.org/licenses/gpl-3.0.html + * + * --------------------------------------------------------------------- + * + * LICENSE + * + * This file is part of GLPI. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * --------------------------------------------------------------------- + */ + +import { BaseConditionEditorController } from './BaseConditionEditorController.js'; + +export class GlpiFormConditionValidationEditorController extends BaseConditionEditorController { + constructor(container, item_uuid, item_type, forms_sections, form_questions, form_comments) { + super( + container, + item_uuid, + item_type, + forms_sections, + form_questions, + form_comments, + `${CFG_GLPI.root_doc}/Form/Condition/Validation/Editor` + ); + } +} diff --git a/js/modules/Forms/ConditionVisibilityEditorController.js b/js/modules/Forms/ConditionVisibilityEditorController.js new file mode 100644 index 00000000000..8ae262d3a3d --- /dev/null +++ b/js/modules/Forms/ConditionVisibilityEditorController.js @@ -0,0 +1,47 @@ +/** + * --------------------------------------------------------------------- + * + * GLPI - Gestionnaire Libre de Parc Informatique + * + * http://glpi-project.org + * + * @copyright 2015-2025 Teclib' and contributors. + * @licence https://www.gnu.org/licenses/gpl-3.0.html + * + * --------------------------------------------------------------------- + * + * LICENSE + * + * This file is part of GLPI. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * --------------------------------------------------------------------- + */ + +import { BaseConditionEditorController } from './BaseConditionEditorController.js'; + +export class GlpiFormConditionVisibilityEditorController extends BaseConditionEditorController { + constructor(container, item_uuid, item_type, forms_sections, form_questions, form_comments) { + super( + container, + item_uuid, + item_type, + forms_sections, + form_questions, + form_comments, + `${CFG_GLPI.root_doc}/Form/Condition/Visibility/Editor` + ); + } +} diff --git a/js/modules/Forms/EditorController.js b/js/modules/Forms/EditorController.js index 6292bb4ffa7..7447af9b3e4 100644 --- a/js/modules/Forms/EditorController.js +++ b/js/modules/Forms/EditorController.js @@ -33,7 +33,8 @@ /* global _, tinymce_editor_configs, getUUID, getRealInputWidth, sortable, tinymce, glpi_toast_info, glpi_toast_error, bootstrap, setupAjaxDropdown, setupAdaptDropdown, setHasUnsavedChanges, hasUnsavedChanges */ -import { GlpiFormConditionEditorController } from 'js/modules/Forms/ConditionEditorController'; +import { GlpiFormConditionVisibilityEditorController } from './ConditionVisibilityEditorController.js'; +import { GlpiFormConditionValidationEditorController } from './ConditionValidationEditorController.js'; /** * Client code to handle users actions on the form_editor template @@ -83,7 +84,7 @@ export class GlpiFormEditorController #question_subtypes_options; /** - * @type {array} + * @type {array} */ #conditions_editors_controllers; @@ -216,6 +217,22 @@ export class GlpiFormEditorController ), ); + // Handle validation editor dropdowns + // The dropdown content will be re-rendered each time it is opened. + // This ensure the selectable data is always up to date (i.e. the + // question selector has up to date questions names, contains all newly + // added questions and do not include deleted questions). + $(document) + .on( + 'show.bs.dropdown', + '[data-glpi-form-editor-validation-dropdown]', + (e) => this.#renderValidationEditor( + $(e.target) + .parent() + .find('[data-glpi-conditions-editor-container]') + ), + ); + // Compute state before submitting the form $(this.#target).on('submit', (event) => { try { @@ -255,7 +272,7 @@ export class GlpiFormEditorController // Handle conditions strategy changes document.addEventListener('updated_strategy', (e) => { - this.#updateVisibilityBadge( + this.#updateConditionBadge( $(e.detail.container).closest( '[data-glpi-form-editor-block],[data-glpi-form-editor-section-details],[data-glpi-form-editor-container]' ), @@ -500,6 +517,12 @@ export class GlpiFormEditorController ); break; + case "show-validation-dropdown": + this.#showValidationDropdown( + target.closest('[data-glpi-form-editor-block],[data-glpi-form-editor-section-details]') + ); + break; + case "add-horizontal-layout": this.#addHorizontalLayout( target.closest(` @@ -991,6 +1014,9 @@ export class GlpiFormEditorController const new_question = this.#addBlock(target, template); + // Set UUID + this.#setUuid(new_question); + // Mark as active this.#setActiveItem(new_question); @@ -1880,6 +1906,9 @@ export class GlpiFormEditorController const new_comment = this.#addBlock(target, template); + // Set UUID + this.#setUuid(new_comment); + // Mark as active this.#setActiveItem(new_comment); @@ -2435,16 +2464,37 @@ export class GlpiFormEditorController bootstrap.Dropdown.getOrCreateInstance(dropdown[0]).show(); } - #updateVisibilityBadge(container, value) { - // Show/hide badges in the container - container.find('[data-glpi-editor-visibility-badge]') + #updateConditionBadge(container, value) { + // Determine which type of badge we're updating based on the container + let badgeType = null; + if (container.find(`[data-glpi-editor-visibility-badge=${value}]`).length > 0) { + badgeType = 'visibility'; + } else if (container.find(`[data-glpi-editor-validation-badge=${value}]`).length > 0) { + badgeType = 'validation'; + } + + // Hide all badges of this type + container.find(`[data-glpi-editor-${badgeType}-badge]`) .removeClass('d-flex') - .addClass('d-none') - ; - container.find(`[data-glpi-editor-visibility-badge=${value}]`) + .addClass('d-none'); + + // Show only the specific badge for the current value + container.find(`[data-glpi-editor-${badgeType}-badge=${value}]`) .removeClass('d-none') - .addClass('d-flex') + .addClass('d-flex'); + } + + #showValidationDropdown(container) { + container + .find('[data-glpi-form-editor-validation-dropdown-container]') + .removeClass('d-none') + ; + + const dropdown = container + .find('[data-glpi-form-editor-validation-dropdown-container]') + .find('[data-glpi-form-editor-validation-dropdown]') ; + bootstrap.Dropdown.getOrCreateInstance(dropdown[0]).show(); } /** @@ -2541,7 +2591,47 @@ export class GlpiFormEditorController ).data('glpi-form-editor-condition-type'); // Init and register controller - controller = new GlpiFormConditionEditorController( + controller = new GlpiFormConditionVisibilityEditorController( + container[0], + uuid, + type, + this.#getSectionStateForConditionEditor(), + this.#getQuestionStateForConditionEditor(), + this.#getCommentStateForConditionEditor(), + ); + container.attr( + 'data-glpi-editor-condition-controller-index', + this.#conditions_editors_controllers.length, + ); + this.#conditions_editors_controllers.push(controller); + } else { + // Refresh form data to make sure it is up to date + controller.setFormSections(this.#getSectionStateForConditionEditor()); + controller.setFormQuestions(this.#getQuestionStateForConditionEditor()); + controller.setFormComments(this.#getCommentStateForConditionEditor()); + } + + controller.renderEditor(); + } + + async #renderValidationEditor(container) { + let controller = this.#getConditionEditorController(container); + + // Controller lazy loading + if (controller === null) { + // Read selected item uuid and type + const uuid = this.#getItemInput( + container.closest( + '[data-glpi-form-editor-block], [data-glpi-form-editor-section-details], [data-glpi-form-editor-container]' + ), + 'uuid', + ); + const type = container.closest( + '[data-glpi-form-editor-condition-type]' + ).data('glpi-form-editor-condition-type'); + + // Init and register controller + controller = new GlpiFormConditionValidationEditorController( container[0], uuid, type, diff --git a/phpunit/functional/Glpi/Form/AnswersHandler/AnswersHandlerTest.php b/phpunit/functional/Glpi/Form/AnswersHandler/AnswersHandlerTest.php index e87b8fa13ff..6391227b709 100644 --- a/phpunit/functional/Glpi/Form/AnswersHandler/AnswersHandlerTest.php +++ b/phpunit/functional/Glpi/Form/AnswersHandler/AnswersHandlerTest.php @@ -41,6 +41,7 @@ use Glpi\Form\Condition\CreationStrategy; use Glpi\Form\Condition\LogicOperator; use Glpi\Form\Condition\Type; +use Glpi\Form\Condition\ValidationStrategy; use Glpi\Form\Condition\ValueOperator; use Glpi\Form\Condition\VisibilityStrategy; use Glpi\Form\Destination\FormDestinationChange; @@ -54,6 +55,7 @@ use Glpi\Form\QuestionType\QuestionTypeNumber; use Glpi\Form\QuestionType\QuestionTypeShortText; use Glpi\Tests\FormTesterTrait; +use PHPUnit\Framework\Attributes\DataProvider; use User; class AnswersHandlerTest extends DbTestCase @@ -165,115 +167,271 @@ public function testSaveAnswers(): void ); } - public function testValidateAnswers(): void + public static function provideTestValidateAnswers(): iterable { - self::login(); + // Basic mandatory form builder + $mandatory_form_builder = (new FormBuilder("Validation Test Form")) + ->addQuestion("Mandatory Name", QuestionTypeShortText::class, is_mandatory: true) + ->addQuestion("Mandatory Email", QuestionTypeEmail::class, is_mandatory: true) + ->addQuestion("Optional Comment", QuestionTypeLongText::class, is_mandatory: false); + + yield 'All mandatory fields are filled - should be valid' => [ + 'builder' => $mandatory_form_builder, + 'answers' => [ + 'Mandatory Name' => 'John Doe', + 'Mandatory Email' => 'john.doe@example.com', + 'Optional Comment' => 'This is an optional comment', + ], + 'expectedIsValid' => true, + 'expectedErrors' => [], + ]; - // Create a form with both mandatory and optional questions - $builder = new FormBuilder("Validation Test Form"); - $builder - ->addQuestion("Mandatory Name", QuestionTypeShortText::class, is_mandatory: true) - ->addQuestion("Mandatory Email", QuestionTypeEmail::class, is_mandatory: true) - ->addQuestion("Optional Comment", QuestionTypeLongText::class, is_mandatory: false) - ; - $form = self::createForm($builder); + yield 'Missing one mandatory field - should be invalid' => [ + 'builder' => $mandatory_form_builder, + 'answers' => [ + 'Mandatory Email' => 'john.doe@example.com', + 'Optional Comment' => 'This is an optional comment', + ], + 'expectedIsValid' => false, + 'expectedErrors' => [ + 'Mandatory Name' => 'This field is mandatory', + ], + ]; - // Get handler instance - $handler = AnswersHandler::getInstance(); + yield 'Missing all mandatory fields - should be invalid' => [ + 'builder' => $mandatory_form_builder, + 'answers' => [ + 'Optional Comment' => 'This is an optional comment', + ], + 'expectedIsValid' => false, + 'expectedErrors' => [ + 'Mandatory Name' => 'This field is mandatory', + 'Mandatory Email' => 'This field is mandatory', + ], + ]; - // Test 1: All mandatory fields are filled - should be valid - $complete_answers = [ - self::getQuestionId($form, "Mandatory Name") => "John Doe", - self::getQuestionId($form, "Mandatory Email") => "john.doe@example.com", - self::getQuestionId($form, "Optional Comment") => "This is an optional comment", + yield 'Empty answers - should be invalid with multiple errors' => [ + 'builder' => $mandatory_form_builder, + 'answers' => [], + 'expectedIsValid' => false, + 'expectedErrors' => [ + 'Mandatory Name' => 'This field is mandatory', + 'Mandatory Email' => 'This field is mandatory', + ], ]; - $result = $handler->validateAnswers($form, $complete_answers); - $this->assertTrue($result->isValid(), "Validation should pass when all mandatory fields are filled"); - $this->assertCount(0, $result->getErrors(), "There should be no errors when all mandatory fields are filled"); - - // Test 2: Missing one mandatory field - should be invalid - $missing_name_answers = [ - self::getQuestionId($form, "Mandatory Email") => "john.doe@example.com", - self::getQuestionId($form, "Optional Comment") => "This is an optional comment", + + yield 'Empty string in mandatory field - should be invalid' => [ + 'builder' => $mandatory_form_builder, + 'answers' => [ + 'Mandatory Name' => '', + 'Mandatory Email' => 'john.doe@example.com', + ], + 'expectedIsValid' => false, + 'expectedErrors' => [ + 'Mandatory Name' => 'This field is mandatory', + ], ]; - $result = $handler->validateAnswers($form, $missing_name_answers); - $this->assertFalse($result->isValid(), "Validation should fail when a mandatory field is missing"); - $this->assertCount(1, $result->getErrors(), "There should be one error when one mandatory field is missing"); - // Test 3: Missing all mandatory fields - should be invalid with multiple errors - $only_optional_answers = [ - self::getQuestionId($form, "Optional Comment") => "This is an optional comment", + // Conditonnal validation form builder + $validation_conditional_form_builder = (new FormBuilder("Conditional Validation Test Form")) + ->addQuestion("Main Question", QuestionTypeShortText::class, is_mandatory: true) + ->addQuestion("Conditional Question", QuestionTypeShortText::class, is_mandatory: true); + $validation_conditional_form_builder->setQuestionValidation( + "Conditional Question", + ValidationStrategy::VALID_IF, + [ + [ + 'logic_operator' => LogicOperator::AND, + 'item_name' => "Conditional Question", + 'item_type' => Type::QUESTION, + 'value_operator' => ValueOperator::MATCH_REGEX, + 'value' => "/^Conditional Validation$/", + ], + ] + ); + + yield 'Validation condition met - should be valid' => [ + 'builder' => $validation_conditional_form_builder, + 'answers' => [ + 'Main Question' => 'Show Conditional', + 'Conditional Question' => 'Conditional Validation', + ], + 'expectedIsValid' => true, + 'expectedErrors' => [], ]; - $result = $handler->validateAnswers($form, $only_optional_answers); - $this->assertFalse($result->isValid(), "Validation should fail when all mandatory fields are missing"); - $this->assertCount(2, $result->getErrors(), "There should be two errors when both mandatory fields are missing"); - - // Test 4: Empty answers - should be invalid with multiple errors - $empty_answers = []; - $result = $handler->validateAnswers($form, $empty_answers); - $this->assertFalse($result->isValid(), "Validation should fail when answers are empty"); - $this->assertCount(2, $result->getErrors(), "There should be two errors when answers are empty"); - - // Test 5: Empty string in mandatory field - should be invalid - $empty_string_answers = [ - self::getQuestionId($form, "Mandatory Name") => "", - self::getQuestionId($form, "Mandatory Email") => "john.doe@example.com", + + yield 'Validation condition not met - should be invalid' => [ + 'builder' => $validation_conditional_form_builder, + 'answers' => [ + 'Main Question' => 'Show Conditional', + 'Conditional Question' => 'Invalid answer', + ], + 'expectedIsValid' => false, + 'expectedErrors' => [ + 'Conditional Question' => 'The value must match the requested format', + ], ]; - $result = $handler->validateAnswers($form, $empty_string_answers); - $this->assertFalse($result->isValid(), "Validation should fail when a mandatory field contains an empty string"); - $this->assertCount(1, $result->getErrors(), "There should be one error when a mandatory field contains an empty string"); - } - public function testValidateAnswersWithConditions(): void - { - self::login(); + yield 'Empty answer in conditional question - should be invalid' => [ + 'builder' => $validation_conditional_form_builder, + 'answers' => [ + 'Main Question' => 'Show Conditional', + 'Conditional Question' => '', + ], + 'expectedIsValid' => false, + 'expectedErrors' => [ + 'Conditional Question' => 'This field is mandatory', + ], + ]; - // Create a form with conditional questions - $builder = new FormBuilder("Conditional Validation Test Form"); - $builder + // Conditional visibility form builder + $visibility_conditional_form_builder = (new FormBuilder("Conditional Validation Test Form")) ->addQuestion("Main Question", QuestionTypeShortText::class, is_mandatory: true) - ->addQuestion("Conditional Question", QuestionTypeShortText::class, is_mandatory: true) - ->setQuestionVisibility( - "Conditional Question", - VisibilityStrategy::VISIBLE_IF, + ->addQuestion("Conditional Question", QuestionTypeShortText::class, is_mandatory: true); + $visibility_conditional_form_builder->setQuestionVisibility( + "Conditional Question", + VisibilityStrategy::VISIBLE_IF, + [ [ - [ - 'logic_operator' => LogicOperator::AND, - 'item_name' => "Main Question", - 'item_type' => Type::QUESTION, - 'value_operator' => ValueOperator::EQUALS, - 'value' => "Show Conditional", - ], - ] - ) - ; - $form = self::createForm($builder); + 'logic_operator' => LogicOperator::AND, + 'item_name' => "Main Question", + 'item_type' => Type::QUESTION, + 'value_operator' => ValueOperator::EQUALS, + 'value' => "Show Conditional", + ], + ] + ); - // Get handler instance - $handler = AnswersHandler::getInstance(); + yield 'Conditional question shown and filled - should be valid' => [ + 'builder' => $visibility_conditional_form_builder, + 'answers' => [ + 'Main Question' => 'Show Conditional', + 'Conditional Question' => 'This is a conditional answer', + ], + 'expectedIsValid' => true, + 'expectedErrors' => [], + ]; + + yield 'Conditional question not shown - should be valid' => [ + 'builder' => $visibility_conditional_form_builder, + 'answers' => [ + 'Main Question' => 'Do not show', + ], + 'expectedIsValid' => true, + 'expectedErrors' => [], + ]; + + yield 'Conditional question shown but not filled - should be invalid' => [ + 'builder' => $visibility_conditional_form_builder, + 'answers' => [ + 'Main Question' => 'Show Conditional', + 'Conditional Question' => '', + ], + 'expectedIsValid' => false, + 'expectedErrors' => [ + 'Conditional Question' => 'This field is mandatory', + ], + ]; + + // Both validation and visibility form builder + $both_conditional_form_builder = (new FormBuilder("Both Conditional Validation Test Form")) + ->addQuestion("Main Question", QuestionTypeShortText::class, is_mandatory: true) + ->addQuestion("Conditional Question", QuestionTypeShortText::class, is_mandatory: true); + $both_conditional_form_builder->setQuestionVisibility( + "Conditional Question", + VisibilityStrategy::VISIBLE_IF, + [ + [ + 'logic_operator' => LogicOperator::AND, + 'item_name' => "Main Question", + 'item_type' => Type::QUESTION, + 'value_operator' => ValueOperator::EQUALS, + 'value' => "Show Conditional", + ], + ] + ); + $both_conditional_form_builder->setQuestionValidation( + "Conditional Question", + ValidationStrategy::VALID_IF, + [ + [ + 'logic_operator' => LogicOperator::AND, + 'item_name' => "Conditional Question", + 'item_type' => Type::QUESTION, + 'value_operator' => ValueOperator::MATCH_REGEX, + 'value' => "/^Both Conditional Validation$/", + ], + ] + ); - // Test 1: Conditional question is shown - should be valid - $conditional_answers = [ - self::getQuestionId($form, "Main Question") => "Show Conditional", - self::getQuestionId($form, "Conditional Question") => "This is a conditional answer", + yield 'Conditional question shown and validation condition met - should be valid' => [ + 'builder' => $both_conditional_form_builder, + 'answers' => [ + 'Main Question' => 'Show Conditional', + 'Conditional Question' => 'Both Conditional Validation', + ], + 'expectedIsValid' => true, + 'expectedErrors' => [], ]; - $result = $handler->validateAnswers($form, $conditional_answers); - $this->assertTrue($result->isValid(), "Validation should pass when the conditional question is shown and filled"); - // Test 2: Conditional question is not shown - should be valid - $non_conditional_answers = [ - self::getQuestionId($form, "Main Question") => "Do not show", + yield 'Conditional question shown but validation condition not met - should be invalid' => [ + 'builder' => $both_conditional_form_builder, + 'answers' => [ + 'Main Question' => 'Show Conditional', + 'Conditional Question' => 'Invalid answer', + ], + 'expectedIsValid' => false, + 'expectedErrors' => [ + 'Conditional Question' => 'The value must match the requested format', + ], ]; - $result = $handler->validateAnswers($form, $non_conditional_answers); - $this->assertTrue($result->isValid(), "Validation should pass when the conditional question is not shown"); - // Test 3: Conditional question is shown but not filled - should be invalid - $missing_conditional_answers = [ - self::getQuestionId($form, "Main Question") => "Show Conditional", - self::getQuestionId($form, "Conditional Question") => "", + yield 'Conditional question not shown and not filled - should be valid' => [ + 'builder' => $both_conditional_form_builder, + 'answers' => [ + 'Main Question' => 'Do not show', + ], + 'expectedIsValid' => true, + 'expectedErrors' => [], ]; - $result = $handler->validateAnswers($form, $missing_conditional_answers); - $this->assertFalse($result->isValid(), "Validation should fail when the conditional question is shown but not filled"); + } + + #[DataProvider('provideTestValidateAnswers')] + public function testValidateAnswers( + FormBuilder $builder, + array $answers, + bool $expectedIsValid, + array $expectedErrors + ): void { + self::login(); + + $form = self::createForm($builder); + $handler = AnswersHandler::getInstance(); + + // Convert answer keys from question names to question IDs + $mapped_answers = []; + foreach ($answers as $question_name => $answer) { + $question_id = self::getQuestionId($form, $question_name); + $mapped_answers[$question_id] = $answer; + } + $result = $handler->validateAnswers($form, $mapped_answers); + + $this->assertEquals($expectedIsValid, $result->isValid(), "Validation result should match expected value"); + $this->assertCount(count($expectedErrors), $result->getErrors(), "Number of errors should match expected value"); + + // Convert expected errors to expected format + $currentErrors = $result->getErrors(); + foreach ($expectedErrors as $name => $error) { + $this->assertContains( + [ + 'question_id' => self::getQuestionId($form, $name), + 'question_name' => $name, + 'message' => $error, + ], + $currentErrors, + "Expected error message should be present in the result" + ); + } } private function validateAnswers( diff --git a/src/Glpi/Controller/Form/Condition/EditorController.php b/src/Glpi/Controller/Form/Condition/EditorController.php index 51f1ed8ab7f..e6351d40293 100644 --- a/src/Glpi/Controller/Form/Condition/EditorController.php +++ b/src/Glpi/Controller/Form/Condition/EditorController.php @@ -35,8 +35,13 @@ namespace Glpi\Controller\Form\Condition; use Glpi\Controller\AbstractController; +use Glpi\Form\Condition\ConditionData; use Glpi\Form\Condition\EditorManager; use Glpi\Form\Condition\FormData; +use Glpi\Form\Condition\QuestionData; +use Glpi\Form\Condition\Type; +use Glpi\Form\Question; +use InvalidArgumentException; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; @@ -48,11 +53,11 @@ public function __construct( ) {} #[Route( - "/Form/Condition/Editor", - name: "glpi_form_condition_editor", + "/Form/Condition/Visibility/Editor", + name: "glpi_form_condition_visibility_editor", methods: "POST" )] - public function __invoke(Request $request): Response + public function visibilityEditor(Request $request): Response { $form_data = $request->request->all()['form_data']; $this->editor_manager->setFormData(new FormData($form_data)); @@ -63,4 +68,66 @@ public function __invoke(Request $request): Response 'items_values' => $this->editor_manager->getItemsDropdownValues(), ]); } + + #[Route( + "/Form/Condition/Validation/Editor", + name: "glpi_form_condition_validation_editor", + methods: "POST" + )] + public function validationEditor(Request $request): Response + { + $form_data = $request->request->all()['form_data']; + $form_data = new FormData($form_data); + $this->editor_manager->setFormData($form_data); + + // Check if the selected item is a question + if ($form_data->getSelectedItemType() !== "question") { + throw new InvalidArgumentException( + sprintf( + 'The selected item type "%s" is not supported for validation editor.', + $form_data->getSelectedItemType() + ) + ); + } + + // Retrieve the question data + $question_uuid = $form_data->getSelectedItemUuid(); + $question_name = current(array_filter( + $form_data->getQuestionsData(), + fn(QuestionData $question) => $question->getUuid() === $question_uuid + ))->getName(); + + // Retrieve the conditions data + $conditions = $form_data->getConditionsData(); + $default_value_operator = current(array_keys($this->editor_manager->getValueOperatorForValidationDropdownValues( + $question_uuid + ))); + if (empty($conditions)) { + $conditions[] = new ConditionData( + item_uuid: $question_uuid, + item_type: Type::QUESTION->value, + value_operator: $default_value_operator, + value: null, + ); + } + + // If the last conditions is empty, we need to add a new one + $last_index = count($conditions) - 1; + if (empty($conditions[$last_index]->getItemUuid())) { + $conditions[$last_index] = new ConditionData( + item_uuid: $question_uuid, + item_type: Type::QUESTION->value, + value_operator: $default_value_operator, + value: null, + ); + } + + return $this->render('pages/admin/form/conditional_validation_editor.html.twig', [ + 'question_uuid' => $question_uuid, + 'question_name' => $question_name, + 'manager' => $this->editor_manager, + 'defined_conditions' => $conditions, + 'items_values' => $this->editor_manager->getItemsDropdownValues(), + ]); + } } diff --git a/src/Glpi/Form/AnswersHandler/AnswersHandler.php b/src/Glpi/Form/AnswersHandler/AnswersHandler.php index edeb8c030e3..bfa0d8f4a44 100644 --- a/src/Glpi/Form/AnswersHandler/AnswersHandler.php +++ b/src/Glpi/Form/AnswersHandler/AnswersHandler.php @@ -40,6 +40,7 @@ use Glpi\Form\AnswersSet; use Glpi\Form\Condition\Engine; use Glpi\Form\Condition\EngineInput; +use Glpi\Form\Condition\ValidationStrategy; use Glpi\Form\DelegationData; use Glpi\Form\Destination\AnswersSet_FormDestinationItem; use Glpi\Form\Destination\FormDestination; @@ -92,6 +93,7 @@ public function validateAnswers( $result = new ValidationResult(); $engine = new Engine($form, new EngineInput($answers)); $visibility = $engine->computeVisibility(); + $validation = $engine->computeValidation(); // Retrieve visible mandatory questions $mandatory_questions = array_filter( @@ -110,6 +112,38 @@ public function validateAnswers( } } + // Validate answers for each question if validation conditions are defined + foreach ($questions_container->getQuestions() as $question) { + if ($question->getConfiguredValidationStrategy() === ValidationStrategy::NO_VALIDATION) { + // Skip validation if the question is always validated + continue; + } + + // Check if the question is visible + if (!$visibility->isQuestionVisible($question->getID())) { + continue; + } + + // Check if the question is not answered (empty or not set) + if (empty($answers[$question->getID()])) { + continue; + } + + // Validate the answer + if (!$validation->isQuestionValid($question->getID())) { + // Add error for each condition that is not met + foreach ($validation->getQuestionValidation($question->getID()) as $condition) { + $result->addError( + $question, + $condition->getValueOperator()?->getErrorMessageForValidation( + $question->getConfiguredValidationStrategy(), + $condition + ) + ); + } + } + } + return $result; } diff --git a/src/Glpi/Form/Condition/ConditionData.php b/src/Glpi/Form/Condition/ConditionData.php index 13648373f41..e133e9ff6f5 100644 --- a/src/Glpi/Form/Condition/ConditionData.php +++ b/src/Glpi/Form/Condition/ConditionData.php @@ -37,6 +37,8 @@ use JsonSerializable; use Override; +use function Safe\json_encode; + final class ConditionData implements JsonSerializable { public function __construct( @@ -84,6 +86,16 @@ public function getValueOperator(): ?ValueOperator return ValueOperator::tryFrom($this->value_operator ?? ""); } + /** + * Compute the UUID of the condition. + * + * @return string + */ + public function getUuid(): string + { + return md5(json_encode($this->jsonSerialize())); + } + #[Override] public function jsonSerialize(): array { diff --git a/src/Glpi/Form/Condition/ConditionableTrait.php b/src/Glpi/Form/Condition/ConditionableTrait.php index 58a2c9a52ab..93914f27dc6 100644 --- a/src/Glpi/Form/Condition/ConditionableTrait.php +++ b/src/Glpi/Form/Condition/ConditionableTrait.php @@ -36,6 +36,8 @@ use JsonException; +use function Safe\json_decode; + trait ConditionableTrait { /** @@ -52,9 +54,13 @@ protected function getConditionsFieldName(): string /** @return ConditionData[] */ public function getConfiguredConditionsData(): array { - parent::post_getFromDB(); + return $this->getConditionsData($this->getConditionsFieldName()); + } - $field_name = $this->getConditionsFieldName(); + /** @return ConditionData[] */ + private function getConditionsData(string $field_name): array + { + parent::post_getFromDB(); try { $raw_data = json_decode( diff --git a/src/Glpi/Form/Condition/ConditionableValidationTrait.php b/src/Glpi/Form/Condition/ConditionableValidationTrait.php new file mode 100644 index 00000000000..7298067e4c5 --- /dev/null +++ b/src/Glpi/Form/Condition/ConditionableValidationTrait.php @@ -0,0 +1,79 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\Condition; + +trait ConditionableValidationTrait +{ + use ConditionableTrait { + getConditionsFieldName as getValidationConditionsFieldName; + getConfiguredConditionsData as getConfiguredValidationConditionsData; + } + + /** + * Get the field name used for visibility strategy + * Classes using this trait can override this method to customize the field name + * + * @return string + */ + protected function getValidationStrategyFieldName(): string + { + return 'validation_strategy'; + } + + /** @return ConditionData[] */ + public function getConfiguredValidationConditionsData(): array + { + return $this->getConditionsData($this->getValidationConditionsFieldName()); + } + + /** + * Override the getConditionsFieldName method from ConditionableTrait + * to return the validation conditions field name + * + * @return string + */ + protected function getValidationConditionsFieldName(): string + { + return 'validation_conditions'; + } + + public function getConfiguredValidationStrategy(): ValidationStrategy + { + $field_name = $this->getValidationStrategyFieldName(); + $strategy_value = $this->fields[$field_name] ?? ""; + $strategy = ValidationStrategy::tryFrom($strategy_value); + return $strategy ?? ValidationStrategy::NO_VALIDATION; + } +} diff --git a/src/Glpi/Form/Condition/EditorManager.php b/src/Glpi/Form/Condition/EditorManager.php index 4f3cc3816df..4fa0952d24c 100644 --- a/src/Glpi/Form/Condition/EditorManager.php +++ b/src/Glpi/Form/Condition/EditorManager.php @@ -178,6 +178,16 @@ public function getValueOperatorDropdownValues(string $uuid): array return $dropdown_values; } + public function getValueOperatorForValidationDropdownValues(string $uuid): array + { + // Filter the value operators to only keep the ones that can be used for validation + return array_filter( + $this->getValueOperatorDropdownValues($uuid), + fn(string $key): bool => ValueOperator::from($key)->canBeUsedForValidation(), + ARRAY_FILTER_USE_KEY, + ); + } + public function getLogicOperatorDropdownValues(): array { return LogicOperator::getDropdownValues(); diff --git a/src/Glpi/Form/Condition/Engine.php b/src/Glpi/Form/Condition/Engine.php index 150800e7eb6..64dd818627e 100644 --- a/src/Glpi/Form/Condition/Engine.php +++ b/src/Glpi/Form/Condition/Engine.php @@ -96,6 +96,21 @@ public function computeVisibility(): EngineVisibilityOutput return $output; } + public function computeValidation(): EngineValidationOutput + { + $validation = new EngineValidationOutput(); + + // Compute questions validation + foreach ($this->form->getQuestions() as $question) { + $validation->setQuestionValidation( + $question->getID(), + $this->computeItemValidation($question), + ); + } + + return $validation; + } + public function computeItemsThatMustBeCreated(): EngineCreationOutput { $output = new EngineCreationOutput(); @@ -151,6 +166,32 @@ private function computeItemVisibility(ConditionableVisibilityInterface $item): } } + /** + * @param Question $question + * @return ConditionData[] + */ + private function computeItemValidation(Question $question): array + { + // Stop immediatly if the strategy result is forced. + $strategy = $question->getConfiguredValidationStrategy(); + if ($strategy == ValidationStrategy::NO_VALIDATION) { + return []; + } + + // Compute the conditions + $conditions = $question->getConfiguredValidationConditionsData(); + $result_per_condition = []; + $conditions_result = $this->computeConditions($conditions, $result_per_condition); + if ($strategy->mustBeValidated($conditions_result)) { + return []; + } + + return array_filter(array_map( + fn(ConditionData $condition): ?ConditionData => $strategy->mustBeValidated($result_per_condition[$condition->getUuid()]) ? null : $condition, + $conditions, + )); + } + private function computeDestinationCreation(ConditionableCreationInterface $item): bool { // Stop immediatly if the strategy result is forced. @@ -166,7 +207,12 @@ private function computeDestinationCreation(ConditionableCreationInterface $item return $strategy->mustBeCreated($conditions_result); } - private function computeConditions(array $conditions): bool + /** + * @param ConditionData[] $conditions + * @param array &$result_per_condition + * @return bool + */ + private function computeConditions(array $conditions, array &$result_per_condition = []): bool { $conditions_result = null; foreach ($conditions as $condition) { @@ -176,6 +222,7 @@ private function computeConditions(array $conditions): bool // Apply condition (item + value operator + value) $condition_result = $this->computeCondition($condition); + $result_per_condition[$condition->getUuid()] = $condition_result; // Apply logic operator if ($conditions_result === null) { diff --git a/src/Glpi/Form/Condition/EngineValidationOutput.php b/src/Glpi/Form/Condition/EngineValidationOutput.php new file mode 100644 index 00000000000..e33d3648596 --- /dev/null +++ b/src/Glpi/Form/Condition/EngineValidationOutput.php @@ -0,0 +1,79 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\Condition; + +use JsonSerializable; +use Override; + +final class EngineValidationOutput implements JsonSerializable +{ + /** @var array */ + private array $questions_validation = []; + + #[Override] + public function jsonSerialize(): array + { + return [ + 'questions_validation' => $this->questions_validation, + ]; + } + + public function setQuestionValidation(int $question_id, array $not_met_conditions): void + { + $this->questions_validation[$question_id] = $not_met_conditions; + } + + /** + * @param int $question_id + * @return ConditionData[] + */ + public function getQuestionValidation(int $question_id): array + { + if (!isset($this->questions_validation[$question_id])) { + return []; + } + + return $this->questions_validation[$question_id]; + } + + public function isQuestionValid(int $question_id): bool + { + if (!isset($this->questions_validation[$question_id])) { + return false; + } + + return empty($this->questions_validation[$question_id]); + } +} diff --git a/src/Glpi/Form/Condition/ValidationStrategy.php b/src/Glpi/Form/Condition/ValidationStrategy.php new file mode 100644 index 00000000000..3dce7efc826 --- /dev/null +++ b/src/Glpi/Form/Condition/ValidationStrategy.php @@ -0,0 +1,83 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\Condition; + +use Override; + +enum ValidationStrategy: string implements StrategyInterface +{ + case NO_VALIDATION = 'no_validation'; + case VALID_IF = 'valid_if'; + case INVALID_IF = 'invalid_if'; + + #[Override] + public function getLabel(): string + { + return match ($this) { + self::NO_VALIDATION => __('No validation'), + self::VALID_IF => __('Valid if...'), + self::INVALID_IF => __('Invalid if...'), + }; + } + + #[Override] + public function getIcon(): string + { + return match ($this) { + self::NO_VALIDATION => 'ti ti-filter', + self::VALID_IF => 'ti ti-filter-cog', + self::INVALID_IF => 'ti ti-filter-x', + }; + } + + #[Override] + public function showEditor(): bool + { + return match ($this) { + self::NO_VALIDATION => false, + self::VALID_IF => true, + self::INVALID_IF => true, + }; + } + + public function mustBeValidated(bool $conditions_result): bool + { + return match ($this) { + self::NO_VALIDATION => true, + self::VALID_IF => $conditions_result, + self::INVALID_IF => !$conditions_result, + }; + } +} diff --git a/src/Glpi/Form/Condition/ValueOperator.php b/src/Glpi/Form/Condition/ValueOperator.php index c4ae7047c9b..dc2a4f2bc53 100644 --- a/src/Glpi/Form/Condition/ValueOperator.php +++ b/src/Glpi/Form/Condition/ValueOperator.php @@ -76,4 +76,49 @@ public function getLabel(): string self::NOT_VISIBLE => __("Is not visible"), }; } + + public function canBeUsedForValidation(): bool + { + return match ($this) { + self::GREATER_THAN, + self::GREATER_THAN_OR_EQUALS, + self::LESS_THAN, + self::LESS_THAN_OR_EQUALS, + self::MATCH_REGEX, + self::NOT_MATCH_REGEX => true, + + default => false + }; + } + + public function getErrorMessageForValidation( + ValidationStrategy $validation_strategy, + ConditionData $condition_data + ): string { + if ($validation_strategy === ValidationStrategy::VALID_IF) { + return match ($this) { + self::GREATER_THAN => sprintf(__("The value must be greater than %s"), $condition_data->getValue()), + self::GREATER_THAN_OR_EQUALS => sprintf(__("The value must be greater than or equal to %s"), $condition_data->getValue()), + self::LESS_THAN => sprintf(__("The value must be less than %s"), $condition_data->getValue()), + self::LESS_THAN_OR_EQUALS => sprintf(__("The value must be less than or equal to %s"), $condition_data->getValue()), + self::MATCH_REGEX => __("The value must match the requested format"), + self::NOT_MATCH_REGEX => __("The value must not match the requested format"), + + default => __("The value is not valid"), + }; + } elseif ($validation_strategy === ValidationStrategy::INVALID_IF) { + return match ($this) { + self::GREATER_THAN => sprintf(__("The value must not be greater than %s"), $condition_data->getValue()), + self::GREATER_THAN_OR_EQUALS => sprintf(__("The value must not be greater than or equal to %s"), $condition_data->getValue()), + self::LESS_THAN => sprintf(__("The value must not be less than %s"), $condition_data->getValue()), + self::LESS_THAN_OR_EQUALS => sprintf(__("The value must not be less than or equal to %s"), $condition_data->getValue()), + self::MATCH_REGEX => __("The value must not match the requested format"), + self::NOT_MATCH_REGEX => __("The value must match the requested format"), + + default => __("The value is not valid"), + }; + } + + return __("The value is not valid"); + } } diff --git a/src/Glpi/Form/Question.php b/src/Glpi/Form/Question.php index 66030a01c12..7639d2d7f23 100644 --- a/src/Glpi/Form/Question.php +++ b/src/Glpi/Form/Question.php @@ -39,6 +39,7 @@ use Glpi\Application\View\TemplateRenderer; use Glpi\DBAL\JsonFieldInterface; use Glpi\Form\AccessControl\FormAccessControlManager; +use Glpi\Form\Condition\ConditionableValidationTrait; use Glpi\Form\Condition\ConditionableVisibilityInterface; use Glpi\Form\Condition\ConditionableVisibilityTrait; use Glpi\Form\Export\Context\DatabaseMapper; @@ -60,6 +61,7 @@ final class Question extends CommonDBChild implements BlockInterface, ConditionableVisibilityInterface { use ConditionableVisibilityTrait; + use ConditionableValidationTrait; public const TRANSLATION_KEY_NAME = 'question_name'; public const TRANSLATION_KEY_DESCRIPTION = 'question_description'; @@ -212,6 +214,10 @@ public function prepareInputForAdd($input) $input['conditions'] = json_encode([]); } + if (!isset($input['validation_conditions'])) { + $input['validation_conditions'] = json_encode([]); + } + $input = $this->prepareInput($input); return parent::prepareInputForAdd($input); } @@ -296,6 +302,11 @@ private function prepareInput($input): array unset($input['_conditions']); } + if (isset($input['_validation_conditions'])) { + $input['validation_conditions'] = json_encode($input['_validation_conditions']); + unset($input['_validation_conditions']); + } + $question_type = $this->getQuestionType(); // The question type can be null when the question is created diff --git a/templates/pages/admin/form/conditional_validation_dropdown.html.twig b/templates/pages/admin/form/conditional_validation_dropdown.html.twig new file mode 100644 index 00000000000..3c88c006e9d --- /dev/null +++ b/templates/pages/admin/form/conditional_validation_dropdown.html.twig @@ -0,0 +1,91 @@ +{# + # --------------------------------------------------------------------- + # + # GLPI - Gestionnaire Libre de Parc Informatique + # + # http://glpi-project.org + # + # @copyright 2015-2025 Teclib' and contributors. + # @licence https://www.gnu.org/licenses/gpl-3.0.html + # + # --------------------------------------------------------------------- + # + # LICENSE + # + # This file is part of GLPI. + # + # This program is free software: you can redistribute it and/or modify + # it under the terms of the GNU General Public License as published by + # the Free Software Foundation, either version 3 of the License, or + # (at your option) any later version. + # + # This program is distributed in the hope that it will be useful, + # but WITHOUT ANY WARRANTY; without even the implied warranty of + # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + # GNU General Public License for more details. + # + # You should have received a copy of the GNU General Public License + # along with this program. If not, see . + # + # --------------------------------------------------------------------- + #} + +{% extends "pages/admin/form/condition_configuration.html.twig" %} + +{# Compute strategy, with a fallback to "Always visible" for new questions #} +{% if item is null %} + {% set question_strategy = enum('Glpi\\Form\\Condition\\ValidationStrategy').NO_VALIDATION %} +{% else %} + {% set question_strategy = item.getConfiguredValidationStrategy() %} +{% endif %} + +{% block conditions_editor %} +
+ + +
+{% endblock %} diff --git a/templates/pages/admin/form/conditional_validation_editor.html.twig b/templates/pages/admin/form/conditional_validation_editor.html.twig new file mode 100644 index 00000000000..0ce3c419b60 --- /dev/null +++ b/templates/pages/admin/form/conditional_validation_editor.html.twig @@ -0,0 +1,155 @@ +{# + # --------------------------------------------------------------------- + # + # GLPI - Gestionnaire Libre de Parc Informatique + # + # http://glpi-project.org + # + # @copyright 2015-2025 Teclib' and contributors. + # @licence https://www.gnu.org/licenses/gpl-3.0.html + # + # --------------------------------------------------------------------- + # + # LICENSE + # + # This file is part of GLPI. + # + # This program is free software: you can redistribute it and/or modify + # it under the terms of the GNU General Public License as published by + # the Free Software Foundation, either version 3 of the License, or + # (at your option) any later version. + # + # This program is distributed in the hope that it will be useful, + # but WITHOUT ANY WARRANTY; without even the implied warranty of + # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + # GNU General Public License for more details. + # + # You should have received a copy of the GNU General Public License + # along with this program. If not, see . + # + # --------------------------------------------------------------------- + #} + +{% set value_operators = manager.getValueOperatorForValidationDropdownValues(question_uuid) %} +{% if value_operators is empty %} +
+ {{ __('Conditional validation is not available for this question type.') }} +
+{% else %} + {% for condition in defined_conditions %} +
+
+
+ {% if not loop.first %} + + {% do call('Dropdown::showFromArray', [ + '_validation_conditions[' ~ loop.index0 ~ '][logic_operator]', + manager.getLogicOperatorDropdownValues(), + { + 'value': condition.getLogicOperator().value, + 'aria_label' : __('Logic operator'), + 'add_data_attributes': { + 'glpi-conditions-editor-logic-operator': '', + }, + }]) %} + + {% endif %} + + {% set question_key = 'question-' ~ question_uuid %} + + {% do call('Dropdown::showFromArray', [ + '_validation_conditions[' ~ loop.index0 ~ '][item]', + { + (question_key): question_name, + }, + { + 'value': question_key, + 'aria_label' : _n('Item', 'Items', 1), + 'add_data_attributes': { + 'glpi-conditions-editor-item': '', + }, + 'display_emptychoice': true, + 'emptylabel': __("Select an item..."), + 'disabled': true + } + ]) %} + + + + + + + + {% set value_op = '' %} + {% if condition.getValueOperator() is not null %} + {% set value_op = condition.getValueOperator().value %} + {% endif %} + {% do call('Dropdown::showFromArray', [ + '_validation_conditions[' ~ loop.index0 ~ '][value_operator]', + value_operators, + { + 'value': value_op, + 'aria_label' : __('Value operator'), + 'add_data_attributes': { + 'glpi-conditions-editor-value-operator': '', + }, + } + ]) %} + + + {# Load the correct template to use #} + {% set handler = manager.getHandlerForCondition(condition) %} + + {% if handler is not null %} + {% if handler.getTemplate() is not null %} + {{ include( + handler.getTemplate(), + { + input_value: condition.getValue(), + input_name: "_validation_conditions[" ~ loop.index0 ~ "][value]", + input_label: __("Value"), + }|merge(handler.getTemplateParameters()), + with_context = false + ) }} + {% endif %} + {% endif %} + + +
+
+
+ {% endfor %} + + +{% endif %} diff --git a/templates/pages/admin/form/conditional_visibility_dropdown.html.twig b/templates/pages/admin/form/conditional_visibility_dropdown.html.twig index 9dd38bde6ca..e46a1dd080a 100644 --- a/templates/pages/admin/form/conditional_visibility_dropdown.html.twig +++ b/templates/pages/admin/form/conditional_visibility_dropdown.html.twig @@ -40,15 +40,12 @@ {% endif %} {% block conditions_editor %} - {# Spacing div (ms-auto must be applied to an item that is always visible) #} -
-