diff --git a/css/includes/_base.scss b/css/includes/_base.scss
index e29070979db..a17161e4e11 100644
--- a/css/includes/_base.scss
+++ b/css/includes/_base.scss
@@ -467,6 +467,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 6d3a4fb830c..97f7b168f4c 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 f64a5372906..abad264e6e3 100644
--- a/dependency_injection/services.php
+++ b/dependency_injection/services.php
@@ -59,6 +59,10 @@
$services->load('Glpi\Kernel\\Listener\\', $projectDir . '/src/Glpi/Kernel/Listener');
$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 a436abbb2e6..022dade3ec0 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`),
diff --git a/install/mysql/glpi-empty.sql b/install/mysql/glpi-empty.sql
index d1584ff9b11..fc2de2768ae 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 ffe038e61cd..670a02feae2 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-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,51 @@ export class GlpiFormEditorController
);
break;
+ case "show-visibility-dropdown":
+ this.#showVisibilityDropdown(
+ target.closest('[data-glpi-form-editor-block],[data-glpi-form-editor-section-details]')
+ );
+ 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 +1030,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 +1882,162 @@ export class GlpiFormEditorController
const form_details = $(this.#target).find("[data-glpi-form-editor-form-details]");
this.#setActiveItem(form_details);
}
+
+ #showVisibilityDropdown(container) {
+ container
+ .find('[data-glpi-form-editor-visibility-dropdown-container]')
+ .removeClass('d-none')
+ ;
+
+ const dropdown = container
+ .find('[data-glpi-form-editor-visibility-dropdown-container]')
+ .find('[data-glpi-form-editor-visibility-dropdown]')
+ ;
+ bootstrap.Dropdown.getOrCreateInstance(dropdown[0]).show();
+ }
+
+ #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..80e5b2da653
--- /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..cb558309c8e
--- /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 6f1b3630037..0590021c636 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..7dd398e5199
--- /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..0f4ba370293
--- /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..936d9965280
--- /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..c13ae3badc5
--- /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..431895dcd42
--- /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..65bbe792d9a
--- /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..e79894bb541
--- /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..d92e04de679
--- /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..736917f3ebc
--- /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..d85845ad2bb
--- /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 fbea126b45a..a4c49b58df4 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\Destination\FormDestination;
diff --git a/src/Glpi/Form/Question.php b/src/Glpi/Form/Question.php
index 93ba62bb64c..1104ecff38b 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';
@@ -151,8 +155,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]
@@ -220,6 +229,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 a8fb30c0ddf..5bf0eb69573 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..f4653754093
--- /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 ca52fab8f66..9ccbd8f07ff 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 3cac3a54243..ef8f4838b40 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..5003cc03aee
--- /dev/null
+++ b/templates/pages/admin/form/conditional_visibility_dropdown.html.twig
@@ -0,0 +1,187 @@
+{#
+ # ---------------------------------------------------------------------
+ #
+ # 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