From bc6fadade1a4486442dbe5f3c760e9133d5a2eed Mon Sep 17 00:00:00 2001 From: AdrienClairembault Date: Tue, 19 Nov 2024 16:22:29 +0100 Subject: [PATCH] Add visibility conditions to questions --- css/includes/_base.scss | 10 + .../components/form/_form-editor.scss | 36 ++ dependency_injection/services.php | 4 + .../update_10.0.x_to_11.0.0/form.php | 13 + install/mysql/glpi-empty.sql | 6 + js/modules/Forms/EditorController.js | 229 ++++++++- .../EditorManagerTest.php | 257 ++++++++++ .../IndexController.php | 68 +++ src/Glpi/Form/Comment.php | 16 +- .../ConditionalVisiblity/ConditionData.php | 84 ++++ .../ConditionnableInterface.php | 58 +++ .../ConditionnableTrait.php | 70 +++ .../ConditionalVisiblity/EditorManager.php | 155 ++++++ .../Form/ConditionalVisiblity/FormData.php | 126 +++++ .../ConditionalVisiblity/LogicOperator.php | 57 +++ .../ConditionalVisiblity/QuestionData.php | 62 +++ src/Glpi/Form/ConditionalVisiblity/Type.php | 42 ++ .../UsedAsCriteriaInterface.php | 44 ++ .../ConditionalVisiblity/ValueOperator.php | 61 +++ .../Form/Export/Serializer/FormSerializer.php | 1 - src/Glpi/Form/Question.php | 20 +- .../QuestionType/QuestionTypeShortText.php | 15 +- src/Glpi/Form/QuestionVisibilityStrategy.php | 69 +++ src/Glpi/Form/Section.php | 29 +- src/Migration.php | 4 + .../conditional_visibility_dropdown.html.twig | 180 +++++++ .../conditional_visibility_editor.html.twig | 140 ++++++ .../pages/admin/form/form_comment.html.twig | 10 +- .../pages/admin/form/form_editor.html.twig | 4 +- .../pages/admin/form/form_question.html.twig | 16 +- .../pages/admin/form/form_section.html.twig | 16 +- tests/cypress/e2e/ajax_controller.cy.js | 2 +- .../cypress/e2e/form/editor/conditions.cy.js | 471 ++++++++++++++++++ .../editor.cy.js} | 0 tests/cypress/support/commands/select2.js | 29 +- 35 files changed, 2375 insertions(+), 29 deletions(-) create mode 100644 phpunit/functional/Glpi/Form/ConditionalVisibility/EditorManagerTest.php create mode 100644 src/Glpi/Controller/Form/ConditionalVisibilityEditor/IndexController.php create mode 100644 src/Glpi/Form/ConditionalVisiblity/ConditionData.php create mode 100644 src/Glpi/Form/ConditionalVisiblity/ConditionnableInterface.php create mode 100644 src/Glpi/Form/ConditionalVisiblity/ConditionnableTrait.php create mode 100644 src/Glpi/Form/ConditionalVisiblity/EditorManager.php create mode 100644 src/Glpi/Form/ConditionalVisiblity/FormData.php create mode 100644 src/Glpi/Form/ConditionalVisiblity/LogicOperator.php create mode 100644 src/Glpi/Form/ConditionalVisiblity/QuestionData.php create mode 100644 src/Glpi/Form/ConditionalVisiblity/Type.php create mode 100644 src/Glpi/Form/ConditionalVisiblity/UsedAsCriteriaInterface.php create mode 100644 src/Glpi/Form/ConditionalVisiblity/ValueOperator.php create mode 100644 src/Glpi/Form/QuestionVisibilityStrategy.php create mode 100644 templates/pages/admin/form/conditional_visibility_dropdown.html.twig create mode 100644 templates/pages/admin/form/conditional_visibility_editor.html.twig create mode 100644 tests/cypress/e2e/form/editor/conditions.cy.js rename tests/cypress/e2e/form/{form_editor.cy.js => editor/editor.cy.js} (100%) diff --git a/css/includes/_base.scss b/css/includes/_base.scss index 6181e9a02e2..c7dd0348697 100644 --- a/css/includes/_base.scss +++ b/css/includes/_base.scss @@ -460,6 +460,16 @@ body pre { } } +// Missing class from tabler, probably need to update it ? +.btn-check:checked+.btn { + color : var(--tblr-btn-hover-color) !important; + background-color: var(--tblr-btn-active-bg) !important; +} + +.w-fit-content { + width: fit-content; +} + .accordion-button:hover, .accordion-button:focus { z-index: unset; } diff --git a/css/includes/components/form/_form-editor.scss b/css/includes/components/form/_form-editor.scss index 50a09cc2202..b648c8dc970 100644 --- a/css/includes/components/form/_form-editor.scss +++ b/css/includes/components/form/_form-editor.scss @@ -290,3 +290,39 @@ } } } + +.logic-operator-selector .select2-selection { + border: 0 !important; + background-color: var(--tblr-pink-lt) !important; + + // Fix some border conflicts with tabler/select2 + border-top-right-radius: var(--tblr-border-radius) !important; + border-bottom-right-radius: var(--tblr-border-radius) !important; +} + +.question-selector .select2-selection { + border: 0 !important; + background-color: var(--tblr-indigo-lt) !important; + + // Fix some border conflicts with tabler/select2 + border-top-right-radius: var(--tblr-border-radius) !important; + border-bottom-right-radius: var(--tblr-border-radius) !important; +} + +.value-operator-selector .select2-selection { + border: 0 !important; + background-color: var(--tblr-teal-lt) !important; + + // Fix some border conflicts with tabler/select2 + border-top-right-radius: var(--tblr-border-radius) !important; + border-bottom-right-radius: var(--tblr-border-radius) !important; +} + +.value-selector { + border: 0 !important; + background-color: var(--tblr-yellow-lt) !important; +} + +.visibility-dropdown-card { + width: 600px; +} diff --git a/dependency_injection/services.php b/dependency_injection/services.php index a7df2ee87a0..e87acdd7698 100644 --- a/dependency_injection/services.php +++ b/dependency_injection/services.php @@ -59,6 +59,10 @@ $services->load('Glpi\Http\\', $projectDir . '/src/Glpi/Http'); $services->load('Glpi\DependencyInjection\\', $projectDir . '/src/Glpi/DependencyInjection'); $services->load('Glpi\Progress\\', $projectDir . '/src/Glpi/Progress')->exclude($projectDir . '/src/Glpi/Progress/SessionProgress.php'); + $services->load( + 'Glpi\Form\ConditionalVisiblity\\', + $projectDir . '/src/Glpi/Form/ConditionalVisiblity/*Manager.php' + ); /** * Override Symfony's logger. 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 c430a730577..c4e3c02532f 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 @@ -103,6 +103,8 @@ `name` varchar(255) NOT NULL DEFAULT '', `description` longtext, `rank` int NOT NULL DEFAULT '0', + `visibility_strategy` varchar(30) NOT NULL DEFAULT '', + `conditions` JSON NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uuid` (`uuid`), KEY `name` (`name`), @@ -124,6 +126,8 @@ `description` longtext, `default_value` text COMMENT 'JSON - The default value type may not be the same for all questions type', `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, PRIMARY KEY (`id`), UNIQUE KEY `uuid` (`uuid`), KEY `name` (`name`), @@ -142,6 +146,8 @@ `name` varchar(255) NOT NULL DEFAULT '', `description` longtext, `rank` int NOT NULL DEFAULT '0', + `visibility_strategy` varchar(30) NOT NULL DEFAULT '', + `conditions` JSON NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uuid` (`uuid`), KEY `name` (`name`), @@ -329,6 +335,13 @@ $migration->addField("glpi_forms_comments", "forms_sections_uuid", "string"); $migration->addKey("glpi_forms_comments", "uuid", type: 'UNIQUE'); $migration->addKey("glpi_forms_comments", "forms_sections_uuid"); + + $migration->addField("glpi_forms_questions", "visibility_strategy", "string"); + $migration->addField("glpi_forms_questions", "conditions", "json"); + $migration->addField("glpi_forms_sections", "visibility_strategy", "string"); + $migration->addField("glpi_forms_sections", "conditions", "json"); + $migration->addField("glpi_forms_comments", "visibility_strategy", "string"); + $migration->addField("glpi_forms_comments", "conditions", "json"); } CronTask::register('Glpi\Form\Form', 'purgedraftforms', DAY_TIMESTAMP, [ diff --git a/install/mysql/glpi-empty.sql b/install/mysql/glpi-empty.sql index 6d99d88ac5d..3214ed0649e 100644 --- a/install/mysql/glpi-empty.sql +++ b/install/mysql/glpi-empty.sql @@ -9504,6 +9504,8 @@ CREATE TABLE `glpi_forms_sections` ( `name` varchar(255) NOT NULL DEFAULT '', `description` longtext, `rank` int NOT NULL DEFAULT '0', + `visibility_strategy` varchar(30) NOT NULL DEFAULT '', + `conditions` JSON NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uuid` (`uuid`), KEY `name` (`name`), @@ -9523,6 +9525,8 @@ CREATE TABLE `glpi_forms_questions` ( `description` longtext, `default_value` text COMMENT 'JSON - The default value type may not be the same for all questions type', `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, PRIMARY KEY (`id`), UNIQUE KEY `uuid` (`uuid`), KEY `name` (`name`), @@ -9539,6 +9543,8 @@ CREATE TABLE `glpi_forms_comments` ( `name` varchar(255) NOT NULL DEFAULT '', `description` longtext, `rank` int NOT NULL DEFAULT '0', + `visibility_strategy` varchar(30) NOT NULL DEFAULT '', + `conditions` JSON NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uuid` (`uuid`), KEY `name` (`name`), diff --git a/js/modules/Forms/EditorController.js b/js/modules/Forms/EditorController.js index 1f85095b294..65facb264e6 100644 --- a/js/modules/Forms/EditorController.js +++ b/js/modules/Forms/EditorController.js @@ -117,6 +117,13 @@ export class GlpiFormEditorController .find("[data-glpi-form-editor-form-details-name]")[0] .select(); } + + this.computeState(); + + // Some radios wont be displayed correclty as checked as they share the same name. + // This is fixed by re-checking them after the state has been computed. + // Not sure if there is a better solution for this, it doesn't feel great. + this.#refreshCheckedInputs(); } /** @@ -139,13 +146,13 @@ export class GlpiFormEditorController () => this.#handleBackendUpdateResponse() ); + // Handle clicks inside the form editor, remove the active item $(document) .on( 'click', '[data-glpi-form-editor]', () => { - this.#setFormDetailsAsActive(); $('.simulate-focus').removeClass('simulate-focus'); } ); @@ -164,6 +171,23 @@ export class GlpiFormEditorController (e, original_event) => this.#handleTinyMCEClick(original_event) ); + + // Handle visiblity 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-visibility-editor-dropdown]', + (e) => this.#renderVisibilityEditor( + $(e.target) + .parent() + .find('[data-glpi-form-editor-visibility-editor]') + ), + ); + // Compute state before submitting the form $(this.#target).on('submit', (event) => { try { @@ -399,6 +423,7 @@ export class GlpiFormEditorController `), ); break; + // Delete the target comment case "delete-comment": this.#deleteComment( @@ -406,6 +431,45 @@ export class GlpiFormEditorController ); break; + // Set the conditional visibility of a section/question/comment + case "set-visiblity-value": { + const input = $(`#${target.attr('for')}`); + this.#setVisibilityValue( + target.closest('[data-glpi-form-editor-block],[data-glpi-form-editor-section-details]'), + input.val() + ); + break; + } + + // Re-render the visibility editor + case "render-visibility-editor": { + this.#renderVisibilityEditor( + $(target).closest( + '[data-glpi-form-editor-visibility-editor]' + ) + ); + break; + } + + // Delete the selected conditon and re-render the visibility editor + case "delete-condition": { + this.#deleteCondition( + $(target).closest('[data-glpi-form-editor-visibility-editor]'), + $(target) + .closest('[data-glpi-form-editor-condition]') + .data('glpi-form-editor-condition-index') + ); + break; + } + + // Add a new empty condition and re-render the visibility editor + case "add-condition": { + this.#addNewEmptyCondition( + $(target).closest('[data-glpi-form-editor-visibility-editor]') + ); + break; + } + // Unknown action default: throw new Error(`Unknown action: ${action}`); @@ -960,6 +1024,24 @@ export class GlpiFormEditorController ); } + // When an input/label are coupled using id/for properties, we must update + // them to make sure they are unique too. + copy.find('input[id]').each(function() { + const id = $(this).attr('id'); + const labels = copy.find(`label[for=${id}]`); + if (labels.length == 0) { + return; + } + + const rand = getUUID(); + const new_id = `${id}_${rand}`; + + $(this).attr('id', new_id); + labels.each(function() { + $(this).attr('for', new_id); + }); + }); + // Insert the new question switch (action) { case "append": @@ -1794,4 +1876,149 @@ export class GlpiFormEditorController const form_details = $(this.#target).find("[data-glpi-form-editor-form-details]"); this.#setActiveItem(form_details); } + + #setVisibilityValue(container, value) { + // Show/hide badges in the container + container.find('[data-glpi-editor-visibility-badge]') + .removeClass('d-flex') + .addClass('d-none') + ; + container.find(`[data-glpi-editor-visibility-badge=${value}]`) + .removeClass('d-none') + .addClass('d-flex') + ; + + // Show/hide the condition editor + const should_displayed_editor = (container + .find(`[data-glpi-form-editor-visibility-editor-display-for-${value}]`) + .length + ) > 0; + container.find(`[data-glpi-form-editor-visibility-editor]`) + .toggleClass('d-none', !should_displayed_editor) + ; + } + + /** + * To render the condition editor, the unsaved state must be computed + * and sent to the server. + * + * This method compute the available questions of the forms, the defined + * conditions and the current selected item. + */ + #getFormStateForVisibilityEditor(container) { + this.computeState(); + + const form_data = { + 'questions': [], + 'conditions': [], + 'selected_item_uuid': this.#getItemInput( + container.closest('[data-glpi-form-editor-block], [data-glpi-form-editor-section-details]'), + 'uuid', + ), + // For now, the type is hardcoded to 'question' but we will support + // conditions on section and comments too + 'selected_item_type': container.closest( + '[data-glpi-form-editor-condition-type]' + ).data('glpi-form-editor-condition-type'), + }; + + // Extract all questions + $(this.#target) + .find("[data-glpi-form-editor-question]") + .each((_index, question) => { + form_data.questions.push({ + 'uuid': this.#getItemInput($(question), "uuid"), + 'name': this.#getItemInput($(question), "name"), + 'type': this.#getItemInput($(question), "type"), + }); + }) + ; + + // Extract already defined conditions for the current question + container.find('[data-glpi-form-editor-condition]') + .each((_index, condition) => { + const condition_data = {}; + + // Try to find a selected logic operator + const condition_logic_operator = $(condition).find( + '[data-glpi-form-editor-condition-logic-operator]' + ); + if (condition_logic_operator.length > 0) { + condition_data.logic_operator = condition_logic_operator.val(); + } + + // Try to find a selected item + const condition_item = $(condition).find( + '[data-glpi-form-editor-condition-item]' + ); + if (condition_item.length > 0) { + condition_data.item = condition_item.val(); + } + + // Try to find a selected value operator + const condition_value_operator = $(condition).find( + '[data-glpi-form-editor-condition-value-operator]' + ); + if (condition_value_operator.length > 0) { + condition_data.value_operator = condition_value_operator.val(); + } + + // Try to find a selected value + const condition_value = $(condition).find( + '[data-glpi-form-editor-condition-value]' + ); + if (condition_value.length > 0) { + condition_data.value = condition_value.val(); + } + + form_data.conditions.push(condition_data); + }) + ; + + return form_data; + } + + async #renderVisibilityEditor(container, form_data = null) { + if (form_data === null) { + form_data = this.#getFormStateForVisibilityEditor(container); + } + + const content = await $.post('/ajax/Form/ConditionalVisibilityEditor', { + form_data: form_data, + }); + container.html(content); + } + + #addNewEmptyCondition(container) { + const form_data = this.#getFormStateForVisibilityEditor(container); + + // Add new empty condition + form_data.conditions.push({ + 'item': '', + }); + + this.#renderVisibilityEditor(container, form_data); + } + + #deleteCondition(container, condition_index) { + const form_data = this.#getFormStateForVisibilityEditor(container); + + // Remove the condition from the list + form_data.conditions = form_data.conditions.filter((_condition, index) => { + return index != condition_index; + }); + + this.#renderVisibilityEditor(container, form_data); + } + + #refreshCheckedInputs() { + $(this.#target) + .find('[data-glpi-editor-refresh-checked]') + .removeProp('checked') + ; + $(this.#target) + .find('[data-glpi-editor-refresh-checked]') + .prop('checked', true) + ; + } } diff --git a/phpunit/functional/Glpi/Form/ConditionalVisibility/EditorManagerTest.php b/phpunit/functional/Glpi/Form/ConditionalVisibility/EditorManagerTest.php new file mode 100644 index 00000000000..93338ec30e9 --- /dev/null +++ b/phpunit/functional/Glpi/Form/ConditionalVisibility/EditorManagerTest.php @@ -0,0 +1,257 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace tests\units\Glpi\Form\ConditionalVisiblity; + +use Glpi\Form\ConditionalVisiblity\ConditionData; +use Glpi\Form\ConditionalVisiblity\EditorManager; +use Glpi\Form\ConditionalVisiblity\FormData; +use Glpi\Form\ConditionalVisiblity\LogicOperator; +use Glpi\Form\ConditionalVisiblity\ValueOperator; +use Glpi\Form\QuestionType\QuestionTypeFile; +use Glpi\Form\QuestionType\QuestionTypeShortText; +use GLPITestCase; + +final class EditorManagerTest extends GLPITestCase +{ + private function getManagerWithData(FormData $form_data): EditorManager + { + $editor_manager = new EditorManager(); + $editor_manager->setFormData($form_data); + return $editor_manager; + } + + public function testDefaultConditionIsAdded(): void + { + // Arrange: create an editor manager with no conditions + $form_data = new FormData([]); + $editor_manager = $this->getManagerWithData($form_data); + + // Act: get the defined conditions + $conditions = $editor_manager->getDefinedConditions(); + + // Assert: the defined conditions should contain a single default condition + $this->assertEquals($conditions, [ + new ConditionData( + item_uuid: '', + item_type: '', + value_operator: null, + value: null, + ) + ]); + } + + public function testConditionsAreFound(): void + { + // Arrange: create an editor manager with two conditions + $form_data = new FormData([ + 'conditions' => [ + [ + 'item' => 'question-1', + 'value_operator' => ValueOperator::EQUALS->value, + 'value' => 'foo', + ], + [ + 'logic_operator' => LogicOperator::OR->value, + 'item' => 'question-2', + 'value_operator' => ValueOperator::EQUALS->value, + 'value' => 'bar', + ], + ], + ]); + $editor_manager = $this->getManagerWithData($form_data); + + // Act: get the defined conditions + $conditions = $editor_manager->getDefinedConditions(); + + // Assert: the two defined conditions should be found + $this->assertEquals($conditions, [ + new ConditionData( + item_uuid: 1, + item_type: 'question', + value_operator: ValueOperator::EQUALS->value, + value: 'foo', + ), + new ConditionData( + item_uuid: 2, + item_type: 'question', + logic_operator: LogicOperator::OR->value, + value_operator: ValueOperator::EQUALS->value, + value: 'bar', + ), + ]); + } + + public function testConditionsHaveDefaultLogicOperators(): void + { + // Arrange: create an editor manager with one conditon without logic operator + $form_data = new FormData([ + 'conditions' => [ + [ + 'item' => 'question-1', + 'value_operator' => ValueOperator::EQUALS->value, + 'value' => 'foo', + ], + ], + ]); + $editor_manager = $this->getManagerWithData($form_data); + + // Act: get the defined condition + $conditions = $editor_manager->getDefinedConditions(); + $condition = array_pop($conditions); + + // Assert: the fallback "AND" operator must be found + $this->assertEquals(LogicOperator::AND, $condition->getLogicOperator()); + } + + public function testOnlyValidQuestionsAreReturnedForDropdowns(): void + { + // Arrange: create an editor manager with three questions + $form_data = new FormData([ + 'questions' => [ + [ + 'uuid' => 1, + 'name' => 'Question 1', + 'type' => QuestionTypeShortText::class, + ], + [ + 'uuid' => 2, + 'name' => 'Question 2', + 'type' => QuestionTypeFile::class, // do not support conditions + ], + [ + 'uuid' => 3, + 'name' => 'Question 3', + 'type' => QuestionTypeShortText::class, + ], + ], + ]); + $editor_manager = $this->getManagerWithData($form_data); + + // Act: get the questions dropdown values + $dropdown_values = $editor_manager->getItemsDropdownValues(); + + // Assert: the dropdown values should not contains the "Question 2" + // question that do not support conditions. + $this->assertEquals($dropdown_values, [ + 'question-1' => 'Question 1', + 'question-3' => 'Question 3', + ]); + } + + public function testSelectedQuestionIsExcludedFromDropdownValues(): void + { + // Arrange: create an editor manager with three questions + // The third question is selected. + $form_data = new FormData([ + 'questions' => [ + [ + 'uuid' => 1, + 'name' => 'Question 1', + 'type' => QuestionTypeShortText::class, + ], + [ + 'uuid' => 2, + 'name' => 'Question 2', + 'type' => QuestionTypeShortText::class, + ], + [ + 'uuid' => 3, + 'name' => 'Question 3', + 'type' => QuestionTypeShortText::class, + ], + ], + 'selected_item_uuid' => 3, + 'selected_item_type' => 'question', + ]); + $editor_manager = $this->getManagerWithData($form_data); + + // Act: get the questions dropdown values + $dropdown_values = $editor_manager->getItemsDropdownValues(); + + // Assert: the dropdown values should not contains "Question 3" as it + // is selected. + $this->assertEquals($dropdown_values, [ + 'question-1' => 'Question 1', + 'question-2' => 'Question 2', + ]); + } + + public function testValueOperatorsForValidQuestionsAreReturned(): void + { + // Arrange: create an editor manager with one question that supports conditions. + $form_data = new FormData([ + 'questions' => [ + [ + 'uuid' => 1, + 'name' => 'Question 1', + 'type' => QuestionTypeShortText::class, + ], + ], + ]); + $editor_manager = $this->getManagerWithData($form_data); + + // Act: get the operators for the question. + $dropdown_values = $editor_manager->getValueOperatorDropdownValues(1); + + // Assert: the expected operators for a "ShortText" questions should be found. + $this->assertEquals([ + 'equals' => __("Is equal to"), + 'not_equals' => __("Is not equal to"), + 'contains' => __("Contains"), + 'not_contains' => __("Do not contains"), + ], $dropdown_values); + } + + public function testValueOperatorsForInvalidQuestionAreNotReturned(): void + { + // Arrange: create an editor manager with three questions + $form_data = new FormData([ + 'questions' => [ + [ + 'uuid' => 2, + 'name' => 'Question 2', + 'type' => QuestionTypeFile::class, // do not support conditions + ], + ], + ]); + $editor_manager = $this->getManagerWithData($form_data); + + // Act: get the operators the question. + $dropdown_values = $editor_manager->getValueOperatorDropdownValues(2); + + // Assert: no operators should be found. + $this->assertEquals([], $dropdown_values); + } +} diff --git a/src/Glpi/Controller/Form/ConditionalVisibilityEditor/IndexController.php b/src/Glpi/Controller/Form/ConditionalVisibilityEditor/IndexController.php new file mode 100644 index 00000000000..5cde1c63471 --- /dev/null +++ b/src/Glpi/Controller/Form/ConditionalVisibilityEditor/IndexController.php @@ -0,0 +1,68 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Controller\Form\ConditionalVisibilityEditor; + +use Glpi\Controller\AbstractController; +use Glpi\Form\ConditionalVisiblity\EditorManager; +use Glpi\Form\ConditionalVisiblity\FormData; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +final class IndexController extends AbstractController +{ + public function __construct( + private EditorManager $editor_manager, + ) { + } + + #[Route( + // Need '/ajax' prefix due to legacy CSRF constraints. + "/ajax/Form/ConditionalVisibilityEditor", + name: "glpi_form_conditional_visibility_editor", + methods: "POST" + )] + public function __invoke(Request $request): Response + { + $form_data = $request->request->all()['form_data']; + $this->editor_manager->setFormData(new FormData($form_data)); + + return $this->render('pages/admin/form/conditional_visibility_editor.html.twig', [ + 'manager' => $this->editor_manager, + 'defined_conditions' => $this->editor_manager->getDefinedConditions(), + 'items_values' => $this->editor_manager->getItemsDropdownValues(), + ]); + } +} diff --git a/src/Glpi/Form/Comment.php b/src/Glpi/Form/Comment.php index fbd4e9ffdcb..0af200a7d80 100644 --- a/src/Glpi/Form/Comment.php +++ b/src/Glpi/Form/Comment.php @@ -37,6 +37,8 @@ use CommonDBChild; use Glpi\Application\View\TemplateRenderer; +use Glpi\Form\ConditionalVisiblity\ConditionnableInterface; +use Glpi\Form\ConditionalVisiblity\ConditionnableTrait; use Log; use Override; use Ramsey\Uuid\Uuid; @@ -44,8 +46,10 @@ /** * Comment of a given helpdesk form's section */ -final class Comment extends CommonDBChild implements BlockInterface +final class Comment extends CommonDBChild implements BlockInterface, ConditionnableInterface { + use ConditionnableTrait; + public static $itemtype = Section::class; public static $items_id = 'forms_sections_id'; @@ -85,6 +89,11 @@ public function prepareInputForAdd($input) $input['uuid'] = Uuid::uuid4(); } + // JSON fields must have a value when created to prevent SQL errors + if (!isset($input['conditions'])) { + $input['conditions'] = json_encode([]); + } + $input = $this->prepareInput($input); return parent::prepareInputForUpdate($input); } @@ -107,6 +116,11 @@ private function prepareInput($input): array $input['forms_sections_uuid'] = $section->fields['uuid']; } + if (isset($input['_conditions'])) { + $input['conditions'] = json_encode($input['_conditions']); + unset($input['_conditions']); + } + return $input; } diff --git a/src/Glpi/Form/ConditionalVisiblity/ConditionData.php b/src/Glpi/Form/ConditionalVisiblity/ConditionData.php new file mode 100644 index 00000000000..1afeb55d19f --- /dev/null +++ b/src/Glpi/Form/ConditionalVisiblity/ConditionData.php @@ -0,0 +1,84 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\ConditionalVisiblity; + +final class ConditionData +{ + public function __construct( + private string $item_uuid, + private string $item_type, + private ?string $value_operator, + private mixed $value, + private ?string $logic_operator = null, + ) { + } + + /** + * Itemtype + uuid, used for dropdowns to allow selecting type + item using + * a single dropdown + */ + public function getItemDropdownKey(): string + { + return $this->item_type . '-' . $this->item_uuid; + } + + public function getItemUuid(): string + { + return $this->item_uuid; + } + + public function getItemType(): string + { + return $this->item_type; + } + + public function getValue(): mixed + { + return $this->value; + } + + public function getLogicOperator(): LogicOperator + { + // Fallback to "AND" if no value is set. + return LogicOperator::tryFrom($this->logic_operator) ?? LogicOperator::AND; + } + + public function getValueOperator(): ?ValueOperator + { + // No follback here as an empty value is valid if the condition is not + // fully specified yet. + return ValueOperator::tryFrom($this->value_operator); + } +} diff --git a/src/Glpi/Form/ConditionalVisiblity/ConditionnableInterface.php b/src/Glpi/Form/ConditionalVisiblity/ConditionnableInterface.php new file mode 100644 index 00000000000..97e540c6837 --- /dev/null +++ b/src/Glpi/Form/ConditionalVisiblity/ConditionnableInterface.php @@ -0,0 +1,58 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\ConditionalVisiblity; + +use Glpi\Form\QuestionVisibilityStrategy; + +/** + * This interface must be satisfied by any form item for which its visibility + * can be toggled depending on some conditions. + */ +interface ConditionnableInterface +{ + /** + * Get configured condition data from the database. + * + * @return ConditionData[] + **/ + public function getConfiguredConditionsData(): array; + + /** + * Get the configured visibility strategy from the database. + * + * @return \Glpi\Form\QuestionVisibilityStrategy + */ + public function getConfiguredVisibilityStrategy(): QuestionVisibilityStrategy; +} diff --git a/src/Glpi/Form/ConditionalVisiblity/ConditionnableTrait.php b/src/Glpi/Form/ConditionalVisiblity/ConditionnableTrait.php new file mode 100644 index 00000000000..0411c703252 --- /dev/null +++ b/src/Glpi/Form/ConditionalVisiblity/ConditionnableTrait.php @@ -0,0 +1,70 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\ConditionalVisiblity; + +use Glpi\Form\QuestionVisibilityStrategy; +use JsonException; + +trait ConditionnableTrait +{ + /** @return ConditionData[] */ + public function getConfiguredConditionsData(): array + { + parent::post_getFromDB(); + + try { + $raw_data = json_decode( + json : $this->fields['conditions'], + associative: true, + flags : JSON_THROW_ON_ERROR, + ); + } catch (JsonException $e) { + $raw_data = []; + } + + $form_data = new FormData([ + 'conditions' => $raw_data, + ]); + + return $form_data->getConditionsData(); + } + + public function getConfiguredVisibilityStrategy(): QuestionVisibilityStrategy + { + $strategy_value = $this->fields['visibility_strategy'] ?? ""; + $strategy = QuestionVisibilityStrategy::tryFrom($strategy_value); + return $strategy ?? QuestionVisibilityStrategy::ALWAYS_VISIBLE; + } +} diff --git a/src/Glpi/Form/ConditionalVisiblity/EditorManager.php b/src/Glpi/Form/ConditionalVisiblity/EditorManager.php new file mode 100644 index 00000000000..8dfa2eaee47 --- /dev/null +++ b/src/Glpi/Form/ConditionalVisiblity/EditorManager.php @@ -0,0 +1,155 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\ConditionalVisiblity; + +final class EditorManager +{ + private FormData $form_data; + + /** + * Current data; must be set before calling others methods. + */ + public function setFormData(FormData $form_data): void + { + $this->form_data = $form_data; + } + + /** + * Get the defined conditions of the current form data. + * Will add a default empty condition if none are defined. + * + * @return ConditionData[] + */ + public function getDefinedConditions(): array + { + $conditions = $this->form_data->getConditionsData(); + if (empty($conditions)) { + $conditions[] = new ConditionData( + item_uuid: '', + item_type: '', + value_operator: null, + value: null, + ); + } + + return $conditions; + } + + /** + * Get a formatted array that is ready to be displayed in a dropdown, with + * each options being a question of the current form data that support + * conditions. + * + * In the future, this will also contains sections as they can be used as + * conditions too (show question X if section xxx is visible...) + * + * @return string[] + */ + public function getItemsDropdownValues(): array + { + $questions_data = $this->form_data->getQuestionsData(); + + $dropdown_values = []; + foreach ($questions_data as $question_data) { + // Make sure the question can be used as a condition criteria. + if (!($question_data->getType() instanceof UsedAsCriteriaInterface)) { + continue; + } + + // Ignore the question that is currently selected as a condition can't + // be used as a criteria for its own visiblity. + if ( + $this->form_data->getSelectedItemType() == Type::QUESTION->value + && $question_data->getUuid() == $this->form_data->getSelectedItemUuid() + ) { + continue; + } + + // Format itemtype + uuid into a single key to allow selected both + // with a simple dropdown. + $key = Type::QUESTION->value . '-' . $question_data->getUuid(); + $dropdown_values[$key] = $question_data->getName(); + } + + return $dropdown_values; + } + + /** + * Get the allowed values operators for the given question using its uuid. + */ + public function getValueOperatorDropdownValues(string $question_uuid): array + { + // Try to find a question for the given uuid. + $question = $this->findQuestionDataByUuid($question_uuid); + if ($question === null) { + return []; + } + + // Make sure the question can be used as a criteria. + $type = $question->getType(); + if (!$type instanceof UsedAsCriteriaInterface) { + return []; + } + + // Load possible value operators + $dropdown_values = []; + foreach ($type->getSupportedValueOperators() as $operator) { + $dropdown_values[$operator->value] = $operator->getLabel(); + } + + return $dropdown_values; + } + + public function getLogicOperatorDropdownValues(): array + { + return LogicOperator::getDropdownValues(); + } + + private function findQuestionDataByUuid(string $question_uuid): ?QuestionData + { + $questions = $this->form_data->getQuestionsData(); + $questions = array_filter( + $questions, + fn (QuestionData $q): bool => $question_uuid == $q->getUuid(), + ); + + if (count($questions) !== 1) { + return null; + } + + $question = array_pop($questions); + return $question; + } +} diff --git a/src/Glpi/Form/ConditionalVisiblity/FormData.php b/src/Glpi/Form/ConditionalVisiblity/FormData.php new file mode 100644 index 00000000000..b01e1e3d183 --- /dev/null +++ b/src/Glpi/Form/ConditionalVisiblity/FormData.php @@ -0,0 +1,126 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\ConditionalVisiblity; + +use Glpi\Form\QuestionType\QuestionTypeInterface; +use ReflectionClass; + +final class FormData +{ + /** @var QuestionData[] $questions_data */ + private array $questions_data = []; + + /** @var ConditionData[] $conditions_data */ + private array $conditions_data = []; + + private string $selected_item_uuid; + private string $selected_item_type; + + public function __construct( + array $raw_data + ) { + $this->parseRawQuestionsData($raw_data['questions'] ?? []); + $this->parseRawConditionsData($raw_data['conditions'] ?? []); + $this->selected_item_uuid = $raw_data['selected_item_uuid'] ?? ''; + $this->selected_item_type = $raw_data['selected_item_type'] ?? ''; + } + + /** @return QuestionData[] */ + public function getQuestionsData(): array + { + return $this->questions_data; + } + + /** @return ConditionData[] */ + public function getConditionsData(): array + { + return $this->conditions_data; + } + + public function getSelectedItemUuid(): string + { + return $this->selected_item_uuid; + } + + public function getSelectedItemType(): string + { + return $this->selected_item_type; + } + + private function parseRawQuestionsData(array $questions_data): void + { + foreach ($questions_data as $question_data) { + $type = $question_data['type'] ?? null; + if ( + !is_a($type, QuestionTypeInterface::class, true) + || (new ReflectionClass($type))->isAbstract() + ) { + continue; + } + + $this->questions_data[] = new QuestionData( + uuid: $question_data['uuid'], + name: $question_data['name'], + type: new $type(), + ); + } + } + + private function parseRawConditionsData(array $conditions_data): void + { + foreach ($conditions_data as $condition_data) { + $item_key = $condition_data['item']; + + if ($item_key == '') { + // Item has not yet been selected. + $type = ''; + $uuid = ''; + } else { + // Item has been selected, extract type and uuid. + $item_parts = explode('-', $item_key); + $type = array_shift($item_parts); + $uuid = implode('-', $item_parts); + } + + $this->conditions_data[] = new ConditionData( + item_uuid : $uuid, + item_type : $type, + value_operator: $condition_data['value_operator'] ?? null, + value : $condition_data['value'] ?? null, + logic_operator: $condition_data['logic_operator'] ?? null, + ); + } + } +} diff --git a/src/Glpi/Form/ConditionalVisiblity/LogicOperator.php b/src/Glpi/Form/ConditionalVisiblity/LogicOperator.php new file mode 100644 index 00000000000..9d74374866c --- /dev/null +++ b/src/Glpi/Form/ConditionalVisiblity/LogicOperator.php @@ -0,0 +1,57 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\ConditionalVisiblity; + +enum LogicOperator: string +{ + case AND = 'and'; + case OR = 'or'; + + public function getLabel(): string + { + return match ($this) { + self::AND => __("And"), + self::OR => __("Or"), + }; + } + + public static function getDropdownValues(): array + { + return [ + self::AND->value => self::AND->getLabel(), + self::OR->value => self::OR->getLabel(), + ]; + } +} diff --git a/src/Glpi/Form/ConditionalVisiblity/QuestionData.php b/src/Glpi/Form/ConditionalVisiblity/QuestionData.php new file mode 100644 index 00000000000..b8c77d5fe72 --- /dev/null +++ b/src/Glpi/Form/ConditionalVisiblity/QuestionData.php @@ -0,0 +1,62 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\ConditionalVisiblity; + +use Glpi\Form\QuestionType\QuestionTypeInterface; + +final class QuestionData +{ + public function __construct( + private string $uuid, + private string $name, + private QuestionTypeInterface $type, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getUuid(): string + { + return $this->uuid; + } + + public function getType(): QuestionTypeInterface + { + return $this->type; + } +} diff --git a/src/Glpi/Form/ConditionalVisiblity/Type.php b/src/Glpi/Form/ConditionalVisiblity/Type.php new file mode 100644 index 00000000000..ed8547e58d7 --- /dev/null +++ b/src/Glpi/Form/ConditionalVisiblity/Type.php @@ -0,0 +1,42 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\ConditionalVisiblity; + +enum Type: string +{ + case QUESTION = 'question'; + case SECTION = 'section'; + case COMMENT = 'comment'; +} diff --git a/src/Glpi/Form/ConditionalVisiblity/UsedAsCriteriaInterface.php b/src/Glpi/Form/ConditionalVisiblity/UsedAsCriteriaInterface.php new file mode 100644 index 00000000000..eb438290924 --- /dev/null +++ b/src/Glpi/Form/ConditionalVisiblity/UsedAsCriteriaInterface.php @@ -0,0 +1,44 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\ConditionalVisiblity; + +/** + * Items that implements this interface can be used as a criteria in a condition. + */ +interface UsedAsCriteriaInterface +{ + /** @return ValueOperator[] */ + public function getSupportedValueOperators(): array; +} diff --git a/src/Glpi/Form/ConditionalVisiblity/ValueOperator.php b/src/Glpi/Form/ConditionalVisiblity/ValueOperator.php new file mode 100644 index 00000000000..bf146449a69 --- /dev/null +++ b/src/Glpi/Form/ConditionalVisiblity/ValueOperator.php @@ -0,0 +1,61 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form\ConditionalVisiblity; + +enum ValueOperator: string +{ + case EQUALS = 'equals'; + case NOT_EQUALS = 'not_equals'; + case CONTAINS = 'contains'; + case NOT_CONTAINS = 'not_contains'; + + // Not yet implemented: + // case GREATER_THAN = 'greater_than'; + // case GREATER_THAN_OR_EQUALS = 'greater_than_or_equals'; + // case LESS_THAN = 'less_than'; + // case LESS_THAN_OR_EQUALS = 'less_than_or_equals'; + // case VISIBLE = 'visible'; + // case NOT_VISIBLE = 'not_visible'; + + public function getLabel(): string + { + return match ($this) { + self::EQUALS => __("Is equal to"), + self::NOT_EQUALS => __("Is not equal to"), + self::CONTAINS => __("Contains"), + self::NOT_CONTAINS => __("Do not contains"), + }; + } +} diff --git a/src/Glpi/Form/Export/Serializer/FormSerializer.php b/src/Glpi/Form/Export/Serializer/FormSerializer.php index 4447fe9d5a4..b7165dfa5a9 100644 --- a/src/Glpi/Form/Export/Serializer/FormSerializer.php +++ b/src/Glpi/Form/Export/Serializer/FormSerializer.php @@ -36,7 +36,6 @@ namespace Glpi\Form\Export\Serializer; use Entity; -use Glpi\DBAL\JsonFieldInterface; use Glpi\Form\AccessControl\FormAccessControl; use Glpi\Form\Comment; use Glpi\Form\Export\Context\DatabaseMapper; diff --git a/src/Glpi/Form/Question.php b/src/Glpi/Form/Question.php index fa924a5d0aa..462aa78503d 100644 --- a/src/Glpi/Form/Question.php +++ b/src/Glpi/Form/Question.php @@ -38,18 +38,22 @@ use CommonDBChild; use Glpi\Application\View\TemplateRenderer; use Glpi\Form\AccessControl\FormAccessControlManager; +use Glpi\Form\ConditionalVisiblity\ConditionnableInterface; +use Glpi\Form\ConditionalVisiblity\ConditionnableTrait; use Glpi\Form\QuestionType\QuestionTypeInterface; use Glpi\Form\QuestionType\QuestionTypesManager; use Log; use Override; -use ReflectionClass; use Ramsey\Uuid\Uuid; +use ReflectionClass; /** * Question of a given helpdesk form's section */ -final class Question extends CommonDBChild implements BlockInterface +final class Question extends CommonDBChild implements BlockInterface, ConditionnableInterface { + use ConditionnableTrait; + public static $itemtype = Section::class; public static $items_id = 'forms_sections_id'; @@ -136,8 +140,13 @@ public function prepareInputForAdd($input) $input['uuid'] = Uuid::uuid4(); } + // JSON fields must have a value when created to prevent SQL errors + if (!isset($input['conditions'])) { + $input['conditions'] = json_encode([]); + } + $input = $this->prepareInput($input); - return parent::prepareInputForUpdate($input); + return parent::prepareInputForAdd($input); } #[Override] @@ -205,6 +214,11 @@ private function prepareInput($input): array } } + if (isset($input['_conditions'])) { + $input['conditions'] = json_encode($input['_conditions']); + unset($input['_conditions']); + } + return $input; } diff --git a/src/Glpi/Form/QuestionType/QuestionTypeShortText.php b/src/Glpi/Form/QuestionType/QuestionTypeShortText.php index f072537f211..f67ff1f46fb 100644 --- a/src/Glpi/Form/QuestionType/QuestionTypeShortText.php +++ b/src/Glpi/Form/QuestionType/QuestionTypeShortText.php @@ -35,9 +35,11 @@ namespace Glpi\Form\QuestionType; +use Glpi\Form\ConditionalVisiblity\UsedAsCriteriaInterface; +use Glpi\Form\ConditionalVisiblity\ValueOperator; use Override; -final class QuestionTypeShortText extends AbstractQuestionTypeShortAnswer +final class QuestionTypeShortText extends AbstractQuestionTypeShortAnswer implements UsedAsCriteriaInterface { #[Override] public function getInputType(): string @@ -62,4 +64,15 @@ public function getWeight(): int { return 10; } + + #[Override] + public function getSupportedValueOperators(): array + { + return [ + ValueOperator::EQUALS, + ValueOperator::NOT_EQUALS, + ValueOperator::CONTAINS, + ValueOperator::NOT_CONTAINS, + ]; + } } diff --git a/src/Glpi/Form/QuestionVisibilityStrategy.php b/src/Glpi/Form/QuestionVisibilityStrategy.php new file mode 100644 index 00000000000..59eed5e1b8a --- /dev/null +++ b/src/Glpi/Form/QuestionVisibilityStrategy.php @@ -0,0 +1,69 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Form; + +enum QuestionVisibilityStrategy: string +{ + case ALWAYS_VISIBLE = 'always_visible'; + case VISIBLE_IF = 'visible_if'; + case HIDDEN_IF = 'hidden_if'; + + public function getLabel(): string + { + return match ($this) { + self::ALWAYS_VISIBLE => __('Always visible'), + self::VISIBLE_IF => __('Visible if...'), + self::HIDDEN_IF => __("Hidden if..."), + }; + } + + public function getIcon(): string + { + return match ($this) { + self::ALWAYS_VISIBLE => 'ti ti-eye', + self::VISIBLE_IF => 'ti ti-eye-cog', + self::HIDDEN_IF => 'ti ti-eye-off', + }; + } + + public function showEditor(): bool + { + return match ($this) { + self::ALWAYS_VISIBLE => false, + self::VISIBLE_IF => true, + self::HIDDEN_IF => true, + }; + } +} diff --git a/src/Glpi/Form/Section.php b/src/Glpi/Form/Section.php index 3c480c07c4b..6899ca6507a 100644 --- a/src/Glpi/Form/Section.php +++ b/src/Glpi/Form/Section.php @@ -36,14 +36,18 @@ namespace Glpi\Form; use CommonDBChild; +use Glpi\Form\ConditionalVisiblity\ConditionnableInterface; +use Glpi\Form\ConditionalVisiblity\ConditionnableTrait; use Override; use Ramsey\Uuid\Uuid; /** * Section of a given helpdesk form */ -final class Section extends CommonDBChild +final class Section extends CommonDBChild implements ConditionnableInterface { + use ConditionnableTrait; + public static $itemtype = Form::class; public static $items_id = 'forms_forms_id'; @@ -92,9 +96,32 @@ public function prepareInputForAdd($input) $input['uuid'] = Uuid::uuid4(); } + // JSON fields must have a value when created to prevent SQL errors + if (!isset($input['conditions'])) { + $input['conditions'] = json_encode([]); + } + + $input = $this->prepareInput($input); + return parent::prepareInputForAdd($input); + } + + #[Override] + public function prepareInputForUpdate($input) + { + $input = $this->prepareInput($input); return parent::prepareInputForUpdate($input); } + private function prepareInput($input): array + { + if (isset($input['_conditions'])) { + $input['conditions'] = json_encode($input['_conditions']); + unset($input['_conditions']); + } + + return $input; + } + /** * Get blocks of this section * Block can be a question or a comment diff --git a/src/Migration.php b/src/Migration.php index 1ce18a2c0c6..7478d3cde2e 100644 --- a/src/Migration.php +++ b/src/Migration.php @@ -365,6 +365,10 @@ private function fieldFormat($type, $default_value, $nodefault = false): string $format = "INT " . DBConnection::getDefaultPrimaryKeySignOption() . " NOT NULL DEFAULT 0"; break; + case 'json': + $format = "JSON NOT NULL"; + break; + default: $format = $type; break; diff --git a/templates/pages/admin/form/conditional_visibility_dropdown.html.twig b/templates/pages/admin/form/conditional_visibility_dropdown.html.twig new file mode 100644 index 00000000000..6c5de330858 --- /dev/null +++ b/templates/pages/admin/form/conditional_visibility_dropdown.html.twig @@ -0,0 +1,180 @@ +{# + # --------------------------------------------------------------------- + # + # GLPI - Gestionnaire Libre de Parc Informatique + # + # http://glpi-project.org + # + # @copyright 2015-2024 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 . + # + # --------------------------------------------------------------------- + #} + +{# Compute strategy, with a fallback to "Always visible" for new questions #} +{% if item is null %} + {% set question_strategy = enum('Glpi\\Form\\QuestionVisibilityStrategy').ALWAYS_VISIBLE %} +{% else %} + {% set question_strategy = item.getConfiguredVisibilityStrategy() %} +{% endif %} + +
+ + +
diff --git a/templates/pages/admin/form/conditional_visibility_editor.html.twig b/templates/pages/admin/form/conditional_visibility_editor.html.twig new file mode 100644 index 00000000000..10411ea1393 --- /dev/null +++ b/templates/pages/admin/form/conditional_visibility_editor.html.twig @@ -0,0 +1,140 @@ +{# + # --------------------------------------------------------------------- + # + # GLPI - Gestionnaire Libre de Parc Informatique + # + # http://glpi-project.org + # + # @copyright 2015-2024 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 last_condition_is_filled = false %} + +{% for condition in defined_conditions %} + {% set condition_is_filled = condition.getItemUuid() != '' %} + {% set last_condition_is_filled = loop.last and condition_is_filled %} + +
+
+
+ {% if not loop.first %} + + {% do call('Dropdown::showFromArray', [ + '_conditions[' ~ loop.index0 ~ '][logic_operator]', + manager.getLogicOperatorDropdownValues(), + { + 'value': condition.getLogicOperator().value, + 'aria_label' : __('Logic operator'), + 'add_data_attributes': { + 'glpi-form-editor-condition-logic-operator': '', + }, + }]) %} + + {% endif %} + + + {% do call('Dropdown::showFromArray', [ + '_conditions[' ~ loop.index0 ~ '][item]', + items_values, + { + 'value': condition.getItemDropdownKey(), + 'aria_label' : _n('Item', 'Items', 1), + 'add_data_attributes': { + 'glpi-form-editor-condition-item': '', + 'glpi-form-editor-on-change': "render-visibility-editor", + }, + 'display_emptychoice': true, + 'emptylabel': __("Select an item..."), + } + ]) %} + + + {% if condition_is_filled %} + + + + + {% do call('Dropdown::showFromArray', [ + '_conditions[' ~ loop.index0 ~ '][value_operator]', + manager.getValueOperatorDropdownValues( + condition.getItemUuid() + ), + { + 'value': condition.getValueOperator().value, + 'aria_label' : __('Value operator'), + 'add_data_attributes': { + 'glpi-form-editor-condition-value-operator': '', + }, + } + ]) %} + + + + {% endif %} + + {% if condition_is_filled or loop.index0 > 0 %} + + {% endif %} +
+
+
+{% endfor %} + +{% if last_condition_is_filled %} + +{% endif %} diff --git a/templates/pages/admin/form/form_comment.html.twig b/templates/pages/admin/form/form_comment.html.twig index 50ecf0a7cf8..a7efc8652fc 100644 --- a/templates/pages/admin/form/form_comment.html.twig +++ b/templates/pages/admin/form/form_comment.html.twig @@ -48,6 +48,7 @@ class="mb-3" data-glpi-form-editor-block data-glpi-form-editor-comment + data-glpi-form-editor-condition-type="{{ enum('Glpi\\Form\\ConditionalVisiblity\\Type').COMMENT.value }}" data-glpi-draggable-item >
* + + {# Visibility dropdown #} + {{ include('pages/admin/form/conditional_visibility_dropdown.html.twig', { + 'item': comment, + }, with_context = false) }} + {# Duplicate comment #}
+
{{ include('pages/admin/form/form_toolbar.html.twig', { 'can_update': can_update, diff --git a/templates/pages/admin/form/form_editor.html.twig b/templates/pages/admin/form/form_editor.html.twig index e20f85fbb1d..3aaa90104d2 100644 --- a/templates/pages/admin/form/form_editor.html.twig +++ b/templates/pages/admin/form/form_editor.html.twig @@ -71,8 +71,6 @@
- - {# We expect to use the right side to display some extra info later so keep some available space for now #}
{# Card containing the main form data: title, header and status #} -
+
@@ -97,10 +98,22 @@ data-glpi-form-editor-question-details-name /> * + + {% if question is null %} + {% set question_strategy = enum('Glpi\\Form\\QuestionVisibilityStrategy').ALWAYS_VISIBLE %} + {% else %} + {% set question_strategy = question.getConfiguredVisibilityStrategy() %} + {% endif %} + + {# Visibility dropdown #} + {{ include('pages/admin/form/conditional_visibility_dropdown.html.twig', { + 'item': question, + }, with_context = false) }} + {# Duplicate question #} +
{{ include('pages/admin/form/form_toolbar.html.twig', { 'can_update': can_update, diff --git a/templates/pages/admin/form/form_section.html.twig b/templates/pages/admin/form/form_section.html.twig index f274e6aee33..55ebfd3b339 100644 --- a/templates/pages/admin/form/form_section.html.twig +++ b/templates/pages/admin/form/form_section.html.twig @@ -50,6 +50,7 @@
@@ -77,7 +78,7 @@ >
{# Header #} -
+
{# Section's name #} + data-glpi-form-editor-dynamic-input + data-glpi-form-editor-on-input="compute-dynamic-input" + /> + + {# Visibility dropdown #} + {{ include('pages/admin/form/conditional_visibility_dropdown.html.twig', { + 'item': section, + }, with_context = false) }} {# Collapse section #} {# Extra actions #} -