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 %}
+
+
+ ---
+ {{ root_item_name }}
+
+
+
+
+
+ {% 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 %}
+
+
+
+ ---
+ {% for sibling in node.siblings %}
+ {{ sibling.name }}
+ {% endfor %}
+
+
+
+
+
+ {% if is_last %}
+
+ {% endif %}
+ {% endfor %}
+
+{% elseif not has_selectable_root %}
+ {% set selector_id = 'tree_cascade_level1_' ~ rand_tree %}
+
+
+ ---
+ {% for item in first_level_items %}
+ {{ item.name }}
+ {% endfor %}
+
+
+
+
+
+
+{% 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
+ # -------------------------------------------------------------------------
+ #}
+
+
+
+ ---
+ {% for child in children %}
+ {{ child.name }}
+ {% endfor %}
+
+
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());
+ }
+}