diff --git a/.phpstan-baseline.php b/.phpstan-baseline.php
index 2a35481e0a7..bcedd729593 100644
--- a/.phpstan-baseline.php
+++ b/.phpstan-baseline.php
@@ -4972,13 +4972,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' => 5,
'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[] = [
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/js/modules/Forms/RendererController.js b/js/modules/Forms/RendererController.js
index 9ebf9fbb65f..638449fcce6 100644
--- a/js/modules/Forms/RendererController.js
+++ b/js/modules/Forms/RendererController.js
@@ -197,6 +197,10 @@ export class GlpiFormRendererController
async #submitForm() {
// Form will be sumitted using an AJAX request instead
try {
+ // Disable actions immediately to avoid someone clicking on the actions
+ // while the form is being submitted.
+ this.#disableActions();
+
// Update tinymce values
if (window.tinymce !== undefined) {
tinymce.get().forEach(editor => {
@@ -205,6 +209,7 @@ export class GlpiFormRendererController
}
if (!await this.#checkCurrentSectionValidity()) {
+ this.#enableActions();
return;
}
@@ -243,6 +248,8 @@ export class GlpiFormRendererController
glpi_toast_error(
__("Failed to submit form, please contact your administrator.")
);
+ } finally {
+ this.#enableActions();
}
}
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/ConditionableTrait.php b/src/Glpi/Form/Condition/ConditionableTrait.php
index 58a2c9a52ab..f8aeff2760c 100644
--- a/src/Glpi/Form/Condition/ConditionableTrait.php
+++ b/src/Glpi/Form/Condition/ConditionableTrait.php
@@ -52,9 +52,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..64d3177aadf 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[spl_object_id($condition)]) ? 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[spl_object_id($condition)] = $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 %}
+
+
+
+
+
+
+
+ {{ __('Conditional validation') }}
+
+
+ {# Parent properties #}
+ {% set strategies = 'Glpi\\Form\\Condition\\ValidationStrategy' %}
+ {% set selected_strategy = question_strategy %}
+ {% set strategy_input_name = "validation_strategy" %}
+ {% set conditions = item is not null ? item.getConfiguredValidationConditionsData() : [] %}
+ {{ parent() }}
+
+
+
+
+{% 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 %}
+