diff --git a/public/js/modules/AfTreeCascadeDropdown.js b/public/js/modules/AfTreeCascadeDropdown.js new file mode 100644 index 0000000..c758d3b --- /dev/null +++ b/public/js/modules/AfTreeCascadeDropdown.js @@ -0,0 +1,147 @@ +/** + * ------------------------------------------------------------------------- + * advancedforms plugin for GLPI + * ------------------------------------------------------------------------- + * + * MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * ------------------------------------------------------------------------- + * @copyright Copyright (C) 2025 by the advancedforms plugin team. + * @license MIT https://opensource.org/licenses/mit-license.php + * @link https://github.com/pluginsGLPI/advancedforms + * ------------------------------------------------------------------------- + */ + +export class AfTreeCascadeDropdown { + /** + * @param {Object} options + * @param {string} options.selector_id - The ID of the select element to bind + * @param {string} options.field_name - The hidden input name for final items_id + * @param {string} options.itemtype - The CommonTreeDropdown itemtype + * @param {string} options.aria_label - Aria label for accessibility + * @param {Object} options.condition - Additional SQL restriction params + * @param {number} options.ajax_limit_count - Limit for Select2 adaptation + * @param {string} [options.next_container_id] - Optional container ID for auto-loading children + * @param {number} [options.auto_load_parent_id] - Optional parent ID to auto-load children on init + * @param {number} [options.level] - Current depth level (1 = root) + */ + constructor(options) { + this.selector_id = options.selector_id; + this.field_name = options.field_name; + this.itemtype = options.itemtype; + this.aria_label = options.aria_label; + this.condition = options.condition || {}; + this.ajax_limit_count = options.ajax_limit_count || 10; + this.next_container_id = options.next_container_id || null; + this.auto_load_parent_id = options.auto_load_parent_id || 0; + this.level = options.level || 1; + this.endpoint_url = `${CFG_GLPI.root_doc}/plugins/advancedforms/TreeDropdownChildren`; + + this.#init(); + } + + #init() { + const $select = $(`#${this.selector_id}`); + if ($select.length === 0) { + return; + } + + this.#setupAdapt($select); + this.#bindChangeEvent($select); + + if (this.auto_load_parent_id > 0 && this.next_container_id) { + this.#loadChildren(this.auto_load_parent_id, $(`#${this.next_container_id}`)); + } + } + + #setupAdapt($select) { + if ($select.hasClass('af-tree-cascade-select')) { + setupAdaptDropdown({ + field_id: this.selector_id, + width: '100%', + dropdown_css_class: '', + placeholder: '', + ajax_limit_count: this.ajax_limit_count, + templateresult: templateResult, + templateselection: templateSelection, + }); + } + } + + #bindChangeEvent($select) { + $select.on('change', () => { + const value = $select.val(); + $(`input[name="${this.field_name}"]`).val(value); + + const $wrapper = $select.closest('.af-tree-level-wrapper'); + $wrapper.nextAll('.af-tree-level-wrapper, .af-tree-next-container').remove(); + + if (value && value > 0) { + const $parentRow = $wrapper.parent().closest('.row, .d-flex, form, [class*="col-"]').length + ? $wrapper.parent() + : $wrapper.parent(); + const $container = $(`
`); + $parentRow.append($container); + this.#loadChildren(value, $container); + } + }); + } + + #loadChildren(parent_id, $container) { + $.ajax({ + url: this.endpoint_url, + data: { + itemtype: this.itemtype, + parent_id: parent_id, + field_name: this.field_name, + aria_label: this.aria_label, + condition: this.condition, + }, + success: (html) => { + if (html.trim().length > 0) { + $container.html(html); + this.#initDynamicChild($container); + } else { + $container.remove(); + } + }, + }); + } + + #initDynamicChild($container) { + const $select = $container.find('.af-tree-cascade-select'); + if ($select.length === 0) { + return; + } + + const child_id = $select.attr('id'); + const child_options = { + selector_id: child_id, + field_name: $select.data('af-tree-field-name'), + itemtype: $select.data('af-tree-itemtype'), + aria_label: $select.data('af-tree-aria-label') || this.aria_label, + condition: $select.data('af-tree-condition') || this.condition, + ajax_limit_count: $select.data('af-tree-ajax-limit') || this.ajax_limit_count, + level: this.level + 1, + }; + + new AfTreeCascadeDropdown(child_options); + } +}; diff --git a/setup.php b/setup.php index a75611f..bdab5bb 100644 --- a/setup.php +++ b/setup.php @@ -31,6 +31,7 @@ * ------------------------------------------------------------------------- */ +use Glpi\Application\ImportMapGenerator; use Glpi\Plugin\HookManager; use GlpiPlugin\Advancedforms\Service\InitManager; @@ -61,6 +62,8 @@ function plugin_init_advancedforms(): void $hook_manager->registerCSSFile('css/advancedforms.css'); $hook_manager->registerJavascriptFile('js/advancedforms.js'); + ImportMapGenerator::getInstance()->registerModulesPath('advancedforms', '/public/js/modules'); + InitManager::getInstance()->init(); } diff --git a/src/Controller/TreeDropdownChildrenController.php b/src/Controller/TreeDropdownChildrenController.php new file mode 100644 index 0000000..e48fcca --- /dev/null +++ b/src/Controller/TreeDropdownChildrenController.php @@ -0,0 +1,141 @@ +query->getString('itemtype', ''); + $parent_id = $request->query->getInt('parent_id', 0); + $field_name = $request->query->getString('field_name', ''); + $aria_label = $request->query->getString('aria_label', ''); + /** @var array $condition_param */ + $condition_param = $request->query->all('condition'); + + if ($parent_id <= 0) { + return new Response('', Response::HTTP_OK); + } + + if (!class_exists($itemtype) || !is_subclass_of($itemtype, CommonTreeDropdown::class)) { + return new Response('', Response::HTTP_OK); + } + + /** @var DBmysql $DB */ + global $DB; + + $foreign_key = $itemtype::getForeignKeyField(); + $table = $itemtype::getTable(); + + $level_key = $table . '.level'; + + $where = []; + + $entity_restrict = getEntitiesRestrictCriteria($table); + if (!empty($entity_restrict)) { + $where = array_merge($where, $entity_restrict); + } + + if (!empty($condition_param) && is_array($condition_param)) { + unset($condition_param[$level_key]); + $where = array_merge($where, $condition_param); + } + + $where[$foreign_key] = $parent_id; + + $item_check = getItemForItemtype($itemtype); + if ($item_check instanceof CommonTreeDropdown && $item_check->isField('is_deleted')) { + $where['is_deleted'] = 0; + } + + $children = []; + $iterator = $DB->request([ + 'SELECT' => ['id', 'name'], + 'FROM' => $table, + 'WHERE' => $where, + 'ORDER' => 'name ASC', + ]); + + foreach ($iterator as $row) { + if (!is_array($row)) { + continue; + } + + $children[] = [ + 'id' => $row['id'], + 'name' => $row['name'], + ]; + } + + if ($children === []) { + return new Response('', Response::HTTP_OK); + } + + global $CFG_GLPI; + + $rand_value = random_int(1000000, 9999999); + $select_id = 'tree_cascade_child_' . $rand_value; + + $twig = TemplateRenderer::getInstance(); + $html = $twig->render( + '@advancedforms/tree_cascade_dropdown_children.html.twig', + [ + 'select_id' => $select_id, + 'children' => $children, + 'final_field_name' => $field_name, + 'aria_label' => $aria_label, + 'itemtype' => $itemtype, + 'condition_param' => $condition_param, + 'ajax_limit_count' => is_numeric($CFG_GLPI['ajax_limit_count'] ?? 10) ? (int) ($CFG_GLPI['ajax_limit_count'] ?? 10) : 10, + ], + ); + + return new Response($html, Response::HTTP_OK, ['Content-Type' => 'text/html; charset=UTF-8']); + } +} diff --git a/src/Model/QuestionType/TreeCascadeDropdownQuestion.php b/src/Model/QuestionType/TreeCascadeDropdownQuestion.php new file mode 100644 index 0000000..e6fba3c --- /dev/null +++ b/src/Model/QuestionType/TreeCascadeDropdownQuestion.php @@ -0,0 +1,392 @@ +itemtype_aria_label = __('Select a dropdown type'); + $this->items_id_aria_label = __('Select a dropdown item'); + } + + /** + * @return array> + */ + #[Override] + public function getAllowedItemtypes(): array + { + return [ + 'Ticket' => [ + Location::class, + ITILCategory::class, + ], + ]; + } + + #[Override] + public function getName(): string + { + return __('Tree Cascade Dropdown', 'advancedforms'); + } + + #[Override] + public function getIcon(): string + { + return 'ti ti-sitemap'; + } + + #[Override] + public function getWeight(): int + { + return 30; + } + + /** + * @return array + */ + #[Override] + public function getDropdownRestrictionParams(?Question $question): array + { + /** @var array */ + return parent::getDropdownRestrictionParams($question); + } + + #[Override] + public function renderEndUserTemplate(Question $question): string + { + global $CFG_GLPI; + + $itemtype = $this->getDefaultValueItemtype($question); + if ($itemtype === null || !is_a($itemtype, CommonTreeDropdown::class, true)) { + return parent::renderEndUserTemplate($question); + } + + $default_items_id = $this->getDefaultValueItemId($question); + $aria_label = $this->items_id_aria_label; + + $tree_table = $itemtype::getTable(); + $foreign_key = $itemtype::getForeignKeyField(); + + $rand_tree = random_int(1000000, 9999999); + $final_items_id_name = $question->getEndUserInputName() . '[items_id]'; + $level2_container = 'level2_container_' . $rand_tree; + + $dropdown_restriction_params = $this->getDropdownRestrictionParams($question); + /** @var array $restriction_where */ + $restriction_where = $dropdown_restriction_params['WHERE'] ?? []; + + $root_items_id = $this->getRootItemsId($question); + $selectable_tree_root = $this->isSelectableTreeRoot($question); + + $root_item_name = ''; + if ($selectable_tree_root && $root_items_id > 0) { + $root_item = getItemForItemtype($itemtype); + if ($root_item instanceof CommonTreeDropdown && $root_item->getFromDB($root_items_id)) { + $root_item_name = is_string($root_item->fields['name'] ?? '') ? (string) ($root_item->fields['name'] ?? '') : ''; + } + } + + $ancestor_chain = $this->buildAncestorChain( + $itemtype, + $default_items_id, + $restriction_where, + $root_items_id, + $selectable_tree_root, + ); + + $first_level_items = $this->getFirstLevelItems( + $itemtype, + $restriction_where, + $root_items_id, + ); + + $twig = TemplateRenderer::getInstance(); + return $twig->render( + '@advancedforms/tree_cascade_dropdown.html.twig', + [ + 'question' => $question, + 'itemtype' => $itemtype, + 'tree_table' => $tree_table, + 'foreign_key' => $foreign_key, + 'default_items_id' => $default_items_id, + 'aria_label' => $aria_label, + 'rand_tree' => $rand_tree, + 'final_items_id_name' => $final_items_id_name, + 'level2_container' => $level2_container, + 'dropdown_restriction_params' => $restriction_where, + 'ancestor_chain' => $ancestor_chain, + 'ajax_limit_count' => is_numeric($CFG_GLPI['ajax_limit_count'] ?? 10) ? (int) ($CFG_GLPI['ajax_limit_count'] ?? 10) : 10, + 'root_items_id' => $root_items_id, + 'selectable_tree_root' => $selectable_tree_root, + 'root_item_name' => $root_item_name, + 'first_level_items' => $first_level_items, + ], + ); + } + + /** + * @param class-string $itemtype + * @param array $extra_conditions + * @return array}> + */ + private function buildAncestorChain( + string $itemtype, + int $items_id, + array $extra_conditions = [], + int $root_items_id = 0, + bool $selectable_tree_root = false, + ): array { + if ($items_id <= 0) { + return []; + } + + if ($selectable_tree_root && $root_items_id > 0 && $items_id === $root_items_id) { + return []; + } + + $item = getItemForItemtype($itemtype); + if (!($item instanceof CommonTreeDropdown) || !$item->getFromDB($items_id)) { + return []; + } + + /** @var DBmysql $DB */ + global $DB; + + $foreign_key = $itemtype::getForeignKeyField(); + $table = $itemtype::getTable(); + $chain = []; + $current = $item; + + while (true) { + /** @var array $fields */ + $fields = $current->fields; + $id = is_numeric($fields['id'] ?? 0) ? (int) ($fields['id'] ?? 0) : 0; + $parent_id_value = is_numeric($fields[$foreign_key] ?? 0) ? (int) ($fields[$foreign_key] ?? 0) : 0; + $level = is_numeric($fields['level'] ?? 0) ? (int) ($fields['level'] ?? 0) : 0; + + if ($root_items_id > 0 && $id === $root_items_id) { + break; + } + + array_unshift($chain, [ + 'id' => $id, + 'parent_id' => $parent_id_value, + 'level' => $level, + 'siblings' => [], + ]); + + $parent_id = is_numeric($fields[$foreign_key] ?? 0) ? (int) ($fields[$foreign_key] ?? 0) : 0; + if ($parent_id <= 0) { + break; + } + + if ($root_items_id > 0 && $parent_id === $root_items_id) { + $chain[0]['parent_id'] = $root_items_id; + break; + } + + $parent = getItemForItemtype($itemtype); + if (!($parent instanceof CommonTreeDropdown) || !$parent->getFromDB($parent_id)) { + break; + } + + $current = $parent; + } + + $entity_restrict = getEntitiesRestrictCriteria($table); + $has_is_deleted = $item->isField('is_deleted'); + + $id_key = $table . '.id'; + $level_key = $table . '.level'; + $filtered_conditions = $extra_conditions; + unset($filtered_conditions[$id_key], $filtered_conditions[$level_key]); + + foreach ($chain as $index => &$node) { + $where = []; + + if (!empty($entity_restrict)) { + $where = array_merge($where, $entity_restrict); + } + + if ($filtered_conditions !== []) { + $where = array_merge($where, $filtered_conditions); + } + + $where[$foreign_key] = $index === 0 ? max($root_items_id, 0) : $node['parent_id']; + + if ($has_is_deleted) { + $where['is_deleted'] = 0; + } + + $siblings = []; + $iterator = $DB->request([ + 'SELECT' => ['id', 'name'], + 'FROM' => $table, + 'WHERE' => $where, + 'ORDER' => 'name ASC', + ]); + + foreach ($iterator as $row) { + /** @var array{id: mixed, name: mixed} $row */ + $row_id = is_numeric($row['id'] ?? 0) ? (int) ($row['id'] ?? 0) : 0; + $row_name = is_string($row['name'] ?? '') ? (string) ($row['name'] ?? '') : ''; + $siblings[] = ['id' => $row_id, 'name' => $row_name]; + } + + $node['siblings'] = $siblings; + } + + return $chain; + } + + /** + * @param class-string $itemtype + * @param array $extra_conditions + * @return array + */ + private function getFirstLevelItems( + string $itemtype, + array $extra_conditions = [], + int $root_items_id = 0, + ): array { + /** @var DBmysql $DB */ + global $DB; + + $table = $itemtype::getTable(); + $foreign_key = $itemtype::getForeignKeyField(); + + $id_key = $table . '.id'; + $level_key = $table . '.level'; + $filtered_conditions = $extra_conditions; + unset($filtered_conditions[$id_key], $filtered_conditions[$level_key]); + + $where = []; + + $entity_restrict = getEntitiesRestrictCriteria($table); + if (!empty($entity_restrict)) { + $where = array_merge($where, $entity_restrict); + } + + if ($filtered_conditions !== []) { + $where = array_merge($where, $filtered_conditions); + } + + $where[$foreign_key] = max($root_items_id, 0); + + $item = getItemForItemtype($itemtype); + if ($item instanceof CommonTreeDropdown && $item->isField('is_deleted')) { + $where['is_deleted'] = 0; + } + + $items = []; + $iterator = $DB->request([ + 'SELECT' => ['id', 'name'], + 'FROM' => $table, + 'WHERE' => $where, + 'ORDER' => 'name ASC', + ]); + + foreach ($iterator as $row) { + /** @var array{id: mixed, name: mixed} $row */ + $row_id = is_numeric($row['id'] ?? 0) ? (int) ($row['id'] ?? 0) : 0; + $row_name = is_string($row['name'] ?? '') ? (string) ($row['name'] ?? '') : ''; + $items[] = ['id' => $row_id, 'name' => $row_name]; + } + + return $items; + } + + #[Override] + public function prepareEndUserAnswer(Question $question, mixed $answer): mixed + { + $question->fields['type'] = QuestionTypeItemDropdown::class; + + return parent::prepareEndUserAnswer($question, $answer); + } + + /** + * @param array $rawData + */ + #[Override] + public function getTargetQuestionType(array $rawData): string + { + return QuestionTypeItemDropdown::class; + } + + #[Override] + public function getConfigDescription(): string + { + return __('Enable tree cascade dropdown question type (recursive dropdown for hierarchical data)', 'advancedforms'); + } + + #[Override] + public static function getConfigKey(): string + { + return 'enable_tree_cascade_dropdown'; + } + + #[Override] + public function getConfigTitle(): string + { + return $this->getName(); + } + + #[Override] + public function getConfigIcon(): string + { + return $this->getIcon(); + } +} diff --git a/src/Service/ConfigManager.php b/src/Service/ConfigManager.php index 7ce6be8..62fc045 100644 --- a/src/Service/ConfigManager.php +++ b/src/Service/ConfigManager.php @@ -42,6 +42,7 @@ use GlpiPlugin\Advancedforms\Model\QuestionType\HostnameQuestion; use GlpiPlugin\Advancedforms\Model\QuestionType\IpAddressQuestion; use GlpiPlugin\Advancedforms\Model\QuestionType\LdapQuestion; +use GlpiPlugin\Advancedforms\Model\QuestionType\TreeCascadeDropdownQuestion; final class ConfigManager { @@ -64,6 +65,7 @@ public function getConfigurableQuestionTypes(): array new HostnameQuestion(), new HiddenQuestion(), new LdapQuestion(), + new TreeCascadeDropdownQuestion(), ]; } @@ -87,7 +89,7 @@ public function getEnabledQuestionsTypes(): array { return array_filter( $this->getConfigurableQuestionTypes(), - fn(ConfigurableItemInterface $c): bool => $this->isConfigurableItemEnabled($c), + $this->isConfigurableItemEnabled(...), ); } diff --git a/templates/tree_cascade_dropdown.html.twig b/templates/tree_cascade_dropdown.html.twig new file mode 100644 index 0000000..e9cbba8 --- /dev/null +++ b/templates/tree_cascade_dropdown.html.twig @@ -0,0 +1,164 @@ +{# + # ------------------------------------------------------------------------- + # advancedforms plugin for GLPI + # ------------------------------------------------------------------------- + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2025 by the advancedforms plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/advancedforms + # ------------------------------------------------------------------------- + #} + +{% import 'components/form/fields_macros.html.twig' as fields %} + +{{ fields.hiddenField(question.getEndUserInputName() ~ '[itemtype]', itemtype) }} +{{ fields.hiddenField(final_items_id_name, default_items_id) }} + +{% set has_selectable_root = selectable_tree_root and root_items_id > 0 and root_item_name is defined %} +{% set root_is_preselected = has_selectable_root and (ancestor_chain|length > 0 or default_items_id == root_items_id) %} + +{% if has_selectable_root %} + {% set root_selector_id = 'tree_cascade_root_' ~ rand_tree %} +
+ +
+ + + + {% if root_is_preselected and ancestor_chain|length == 0 %} +
+ {% endif %} +{% endif %} + +{% if ancestor_chain|length > 0 %} + {% for i, node in ancestor_chain %} + {% set is_last = loop.last %} + {% set level_rand = rand_tree + i %} + {% set selector_id = 'tree_cascade_level_' ~ level_rand %} + {% set level_num = has_selectable_root ? i + 2 : i + 1 %} + +
+ +
+ + + + {% if is_last %} +
+ {% endif %} + {% endfor %} + +{% elseif not has_selectable_root %} + {% set selector_id = 'tree_cascade_level1_' ~ rand_tree %} +
+ +
+ +
+ + +{% endif %} diff --git a/templates/tree_cascade_dropdown_children.html.twig b/templates/tree_cascade_dropdown_children.html.twig new file mode 100644 index 0000000..2616069 --- /dev/null +++ b/templates/tree_cascade_dropdown_children.html.twig @@ -0,0 +1,47 @@ +{# + # ------------------------------------------------------------------------- + # advancedforms plugin for GLPI + # ------------------------------------------------------------------------- + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2025 by the advancedforms plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/advancedforms + # ------------------------------------------------------------------------- + #} + +
+ +
diff --git a/tests/Model/QuestionType/QuestionTypeTestCase.php b/tests/Model/QuestionType/QuestionTypeTestCase.php index a4e8a14..50a802a 100644 --- a/tests/Model/QuestionType/QuestionTypeTestCase.php +++ b/tests/Model/QuestionType/QuestionTypeTestCase.php @@ -239,7 +239,7 @@ private function renderHelpdeskForm(Form $form): Crawler return new Crawler($response->getContent()); } - private function getDefaultExtraDataForQuestionType( + protected function getDefaultExtraDataForQuestionType( QuestionTypeInterface $type, ): ?string { $class = $type->getExtraDataConfigClass(); diff --git a/tests/Model/QuestionType/TreeCascadeDropdownQuestionTest.php b/tests/Model/QuestionType/TreeCascadeDropdownQuestionTest.php new file mode 100644 index 0000000..718a1e0 --- /dev/null +++ b/tests/Model/QuestionType/TreeCascadeDropdownQuestionTest.php @@ -0,0 +1,438 @@ +filter('select[name*="itemtype"]'); + $this->assertNotEmpty($dropdown); + } + + #[Override] + protected function validateHelpdeskRenderingWhenEnabled( + Crawler $html, + ): void { + $select = $html->filter('.af-tree-cascade-select'); + $this->assertNotEmpty($select); + } + + #[Override] + protected function validateHelpdeskRenderingWhenDisabled( + Crawler $html, + ): void { + $select = $html->filter('.af-tree-cascade-select'); + $this->assertEmpty($select); + } + + #[Override] + protected function getDefaultExtraDataForQuestionType( + QuestionTypeInterface $type, + ): ?string { + return json_encode(new QuestionTypeItemDropdownExtraDataConfig( + itemtype: Location::class, + )); + } + + public function testGetName(): void + { + $question_type = new TreeCascadeDropdownQuestion(); + $this->assertNotEmpty($question_type->getName()); + } + + public function testGetIcon(): void + { + $question_type = new TreeCascadeDropdownQuestion(); + $this->assertEquals('ti ti-sitemap', $question_type->getIcon()); + } + + public function testGetWeight(): void + { + $question_type = new TreeCascadeDropdownQuestion(); + $this->assertEquals(30, $question_type->getWeight()); + } + + public function testAllowedItemtypes(): void + { + $question_type = new TreeCascadeDropdownQuestion(); + $allowed = $question_type->getAllowedItemtypes(); + + $this->assertArrayHasKey('Ticket', $allowed); + $this->assertContains(Location::class, $allowed['Ticket']); + $this->assertContains(\ITILCategory::class, $allowed['Ticket']); + } + + public function testGetConfigKey(): void + { + $this->assertEquals( + 'enable_tree_cascade_dropdown', + TreeCascadeDropdownQuestion::getConfigKey(), + ); + } + + public function testHelpdeskRenderingWithLocationHierarchy(): void + { + $this->login(); + $item = $this->getTestedQuestionType(); + $this->enableConfigurableItem($item); + + $entity_id = Session::getActiveEntity(); + + $root = $this->createItem(Location::class, [ + 'name' => 'Root Location', + 'locations_id' => 0, + 'entities_id' => $entity_id, + ]); + + $child = $this->createItem(Location::class, [ + 'name' => 'Child Location', + 'locations_id' => $root->getID(), + 'entities_id' => $entity_id, + ]); + + $grandchild = $this->createItem(Location::class, [ + 'name' => 'Grandchild Location', + 'locations_id' => $child->getID(), + 'entities_id' => $entity_id, + ]); + + $extra_data = json_encode(new QuestionTypeItemDropdownExtraDataConfig( + itemtype: Location::class, + )); + + $builder = new FormBuilder("Tree Cascade Form"); + $builder->addQuestion( + "My location", + TreeCascadeDropdownQuestion::class, + '', + $extra_data, + ); + $form = $this->createForm($builder); + + $html = $this->renderHelpdeskForm($form); + + $selects = $html->filter('.af-tree-cascade-select'); + $this->assertGreaterThanOrEqual(1, $selects->count()); + + $first_select = $selects->eq(0); + $options = $first_select->filter('option')->each( + fn(Crawler $node) => $node->text(), + ); + $this->assertContains('Root Location', $options); + $this->assertNotContains('Child Location', $options); + $this->assertNotContains('Grandchild Location', $options); + } + + public function testHelpdeskRenderingWithDefaultValue(): void + { + $this->login(); + $item = $this->getTestedQuestionType(); + $this->enableConfigurableItem($item); + + $entity_id = Session::getActiveEntity(); + + $root = $this->createItem(Location::class, [ + 'name' => 'Root A', + 'locations_id' => 0, + 'entities_id' => $entity_id, + ]); + + $child = $this->createItem(Location::class, [ + 'name' => 'Child A1', + 'locations_id' => $root->getID(), + 'entities_id' => $entity_id, + ]); + + $extra_data = json_encode(new QuestionTypeItemDropdownExtraDataConfig( + itemtype: Location::class, + )); + + $builder = new FormBuilder("Tree Cascade Preselected"); + $builder->addQuestion( + "My location", + TreeCascadeDropdownQuestion::class, + $child->getID(), + $extra_data, + ); + $form = $this->createForm($builder); + + $html = $this->renderHelpdeskForm($form); + + $hidden_input = $html->filter('input[type="hidden"][value="' . $child->getID() . '"]'); + $this->assertNotEmpty($hidden_input); + + $selects = $html->filter('.af-tree-cascade-select'); + $this->assertGreaterThanOrEqual(2, $selects->count()); + + $first_options = $selects->eq(0)->filter('option')->each( + fn(Crawler $node) => $node->text(), + ); + $this->assertContains('Root A', $first_options); + + $second_select = $selects->eq(1); + $selected_option = $second_select->filter('option[selected]'); + $this->assertNotEmpty($selected_option); + $this->assertEquals('Child A1', $selected_option->text()); + } + + public function testHelpdeskRenderingWithSubtreeRoot(): void + { + $this->login(); + $item = $this->getTestedQuestionType(); + $this->enableConfigurableItem($item); + + $entity_id = Session::getActiveEntity(); + + $root = $this->createItem(Location::class, [ + 'name' => 'Global Root', + 'locations_id' => 0, + 'entities_id' => $entity_id, + ]); + + $subtree_root = $this->createItem(Location::class, [ + 'name' => 'Subtree Root', + 'locations_id' => $root->getID(), + 'entities_id' => $entity_id, + ]); + + $subtree_child = $this->createItem(Location::class, [ + 'name' => 'Subtree Child', + 'locations_id' => $subtree_root->getID(), + 'entities_id' => $entity_id, + ]); + + $extra_data = json_encode(new QuestionTypeItemDropdownExtraDataConfig( + itemtype: Location::class, + root_items_id: $subtree_root->getID(), + )); + + $builder = new FormBuilder("Tree Cascade Subtree"); + $builder->addQuestion( + "My location", + TreeCascadeDropdownQuestion::class, + '', + $extra_data, + ); + $form = $this->createForm($builder); + + $html = $this->renderHelpdeskForm($form); + + $selects = $html->filter('.af-tree-cascade-select'); + $this->assertGreaterThanOrEqual(1, $selects->count()); + + $first_options = $selects->eq(0)->filter('option')->each( + fn(Crawler $node) => $node->text(), + ); + $this->assertContains('Subtree Child', $first_options); + $this->assertNotContains('Global Root', $first_options); + $this->assertNotContains('Subtree Root', $first_options); + } + + public function testHelpdeskRenderingWithSelectableTreeRoot(): void + { + $this->login(); + $item = $this->getTestedQuestionType(); + $this->enableConfigurableItem($item); + + $entity_id = Session::getActiveEntity(); + + $subtree_root = $this->createItem(Location::class, [ + 'name' => 'Selectable Root', + 'locations_id' => 0, + 'entities_id' => $entity_id, + ]); + + $child = $this->createItem(Location::class, [ + 'name' => 'Root Child', + 'locations_id' => $subtree_root->getID(), + 'entities_id' => $entity_id, + ]); + + $extra_data = json_encode(new QuestionTypeItemDropdownExtraDataConfig( + itemtype: Location::class, + root_items_id: $subtree_root->getID(), + selectable_tree_root: true, + )); + + $builder = new FormBuilder("Tree Cascade Selectable Root"); + $builder->addQuestion( + "My location", + TreeCascadeDropdownQuestion::class, + '', + $extra_data, + ); + $form = $this->createForm($builder); + + $html = $this->renderHelpdeskForm($form); + + $selects = $html->filter('.af-tree-cascade-select'); + $this->assertGreaterThanOrEqual(1, $selects->count()); + + $first_options = $selects->eq(0)->filter('option')->each( + fn(Crawler $node) => $node->text(), + ); + $this->assertContains('Selectable Root', $first_options); + $this->assertNotContains('Root Child', $first_options); + } + + public function testFirstDropdownShowsOnlyDirectChildren(): void + { + $this->login(); + $item = $this->getTestedQuestionType(); + $this->enableConfigurableItem($item); + + $entity_id = Session::getActiveEntity(); + + $root_a = $this->createItem(Location::class, [ + 'name' => 'Location A', + 'locations_id' => 0, + 'entities_id' => $entity_id, + ]); + + $root_b = $this->createItem(Location::class, [ + 'name' => 'Location B', + 'locations_id' => 0, + 'entities_id' => $entity_id, + ]); + + $child_a1 = $this->createItem(Location::class, [ + 'name' => 'Location A1', + 'locations_id' => $root_a->getID(), + 'entities_id' => $entity_id, + ]); + + $extra_data = json_encode(new QuestionTypeItemDropdownExtraDataConfig( + itemtype: Location::class, + )); + + $builder = new FormBuilder("Tree Cascade Direct Children"); + $builder->addQuestion( + "My location", + TreeCascadeDropdownQuestion::class, + '', + $extra_data, + ); + $form = $this->createForm($builder); + + $html = $this->renderHelpdeskForm($form); + + $first_select = $html->filter('.af-tree-cascade-select')->eq(0); + $options = $first_select->filter('option')->each( + fn(Crawler $node) => $node->text(), + ); + $this->assertContains('Location A', $options); + $this->assertContains('Location B', $options); + $this->assertNotContains('Location A1', $options); + } + + public function testDropdownShowsNameNotCompletename(): void + { + $this->login(); + $item = $this->getTestedQuestionType(); + $this->enableConfigurableItem($item); + + $entity_id = Session::getActiveEntity(); + + $parent = $this->createItem(Location::class, [ + 'name' => 'Parent Loc', + 'locations_id' => 0, + 'entities_id' => $entity_id, + ]); + + $child = $this->createItem(Location::class, [ + 'name' => 'Child Loc', + 'locations_id' => $parent->getID(), + 'entities_id' => $entity_id, + ]); + + $extra_data = json_encode(new QuestionTypeItemDropdownExtraDataConfig( + itemtype: Location::class, + )); + + $builder = new FormBuilder("Tree Cascade Name Only"); + $builder->addQuestion( + "My location", + TreeCascadeDropdownQuestion::class, + $child->getID(), + $extra_data, + ); + $form = $this->createForm($builder); + + $html = $this->renderHelpdeskForm($form); + + $all_option_texts = $html->filter('.af-tree-cascade-select option')->each( + fn(Crawler $node) => $node->text(), + ); + foreach ($all_option_texts as $text) { + $this->assertStringNotContainsString(' > ', $text); + } + } + + private function renderHelpdeskForm(\Glpi\Form\Form $form): Crawler + { + $this->login(); + $controller = new \Glpi\Controller\Form\RendererController(); + $response = $controller->__invoke( + \Symfony\Component\HttpFoundation\Request::create( + '', + 'GET', + ['id' => $form->getID()], + ), + ); + return new Crawler($response->getContent()); + } +}