diff --git a/public/js/modules/AfTreeCascadeDropdown.js b/public/js/modules/AfTreeCascadeDropdown.js new file mode 100644 index 0000000..36fb1f8 --- /dev/null +++ b/public/js/modules/AfTreeCascadeDropdown.js @@ -0,0 +1,146 @@ +/** + * ------------------------------------------------------------------------- + * 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 container_classes = this.level === 1 ? 'col-12 col-sm-6 af-tree-next-container' : 'af-tree-next-container'; + const $container = $(`
`); + $wrapper.after($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) => { + console.log(this.endpoint_url) + 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..8e5e00f --- /dev/null +++ b/src/Controller/TreeDropdownChildrenController.php @@ -0,0 +1,135 @@ +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(); + + $where = [$foreign_key => $parent_id]; + if (!empty($condition_param) && is_array($condition_param)) { + $where = array_merge($where, $condition_param); + } + + $entity_restrict = getEntitiesRestrictCriteria($table); + if (!empty($entity_restrict)) { + $where = array_merge($where, $entity_restrict); + } + + $item_check = new $itemtype(); + if ($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..88c75a1 --- /dev/null +++ b/src/Model/QuestionType/TreeCascadeDropdownQuestion.php @@ -0,0 +1,289 @@ +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'] ?? []; + + $ancestor_chain = $this->buildAncestorChain($itemtype, $default_items_id, $restriction_where); + + $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, + ], + ); + } + + /** + * @param class-string $itemtype + * @param array $extra_conditions + * @return array}> + */ + private function buildAncestorChain(string $itemtype, int $items_id, array $extra_conditions = []): array + { + if ($items_id <= 0) { + 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; + + 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; + } + + $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'); + + foreach ($chain as &$node) { + $where = []; + if ($node['level'] === 1) { + $where[$table . '.level'] = 1; + } else { + $where[$foreign_key] = $node['parent_id']; + } + + if (!empty($entity_restrict)) { + $where = array_merge($where, $entity_restrict); + } + + if ($extra_conditions !== []) { + $where = array_merge($where, $extra_conditions); + } + + 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; + } + + #[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..d626484 --- /dev/null +++ b/templates/tree_cascade_dropdown.html.twig @@ -0,0 +1,153 @@ +{# + # ------------------------------------------------------------------------- + # 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) }} + +{% if ancestor_chain|length > 0 %} + {% for i, node in ancestor_chain %} + {% set is_last = loop.last %} + {% set level_rand = rand_tree + i %} + + {% if i == 0 %} + {% set temp_name = 'temp_tree_level_' ~ level_rand %} + {% set selector_id = 'dropdown_' ~ temp_name ~ level_rand %} +
+ {{ fields.dropdownField( + itemtype, + temp_name, + node.id, + '', + { + 'init' : true, + 'no_label' : true, + 'right' : 'all', + 'width' : '100%', + 'mb' : '', + 'comments' : false, + 'addicon' : false, + 'aria_label' : aria_label, + 'nochecklimit' : true, + 'display_emptychoice': true, + 'rand' : level_rand, + 'condition' : {(tree_table ~ '.level'): 1}|merge(dropdown_restriction_params), + } + ) }} +
+ {% else %} + {% set selector_id = 'tree_cascade_level_' ~ level_rand %} +
+ +
+ {% endif %} + + + + {% if is_last %} +
+ {% endif %} + {% endfor %} + +{% else %} + {% set temp_level1_name = 'temp_tree_level1_' ~ rand_tree %} + {% set selector_id = 'dropdown_' ~ temp_level1_name ~ rand_tree %} +
+ {{ fields.dropdownField( + itemtype, + temp_level1_name, + 0, + '', + { + 'init' : true, + 'no_label' : true, + 'right' : 'all', + 'width' : '100%', + 'mb' : '', + 'comments' : false, + 'addicon' : false, + 'aria_label' : aria_label, + 'nochecklimit' : true, + 'display_emptychoice': true, + 'rand' : rand_tree, + 'condition' : {(tree_table ~ '.level'): 1}|merge(dropdown_restriction_params), + } + ) }} +
+ +
+ + +{% 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..a9bb1f1 --- /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 + # ------------------------------------------------------------------------- + #} + +
+ +