From e881843a90cd501efa4f206509bb4f0a68f97324 Mon Sep 17 00:00:00 2001 From: Lainow Date: Wed, 23 Sep 2026 17:28:15 +0200 Subject: [PATCH 1/9] Add Escalate Section --- src/Config.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Config.php b/src/Config.php index 365cd16..442eed7 100644 --- a/src/Config.php +++ b/src/Config.php @@ -331,6 +331,7 @@ public static function getScreenTabs(): array ['id' => 'change', 'label' => __('Change'), 'icon' => 'ti-git-branch'], ['id' => 'problem', 'label' => __('Problem'), 'icon' => 'ti-alert-circle'], ['id' => 'task', 'label' => _n('Task', 'Tasks', 2), 'icon' => 'ti-checklist'], + ['id' => 'escalate', 'label' => __('Escalate', 'moreoptions'), 'icon' => 'ti-arrow-up'], ]; } @@ -397,6 +398,13 @@ private static function getScreenSections(): array ], ], ], + 'escalate' => [ + [ + 'title' => __('Escalate', 'moreoptions'), + 'icon' => 'ti-arrow-up', + 'rows' => [], + ], + ], ]; } @@ -409,8 +417,13 @@ private static function getScreenSections(): array */ private static function getSectionsForTab(string $tab_id): array { - $group = $tab_id === 'task' ? 'task' : 'itil'; - $suffix = $tab_id === 'task' ? '' : ('_' . $tab_id); + if ($tab_id === 'task' || $tab_id === 'escalate') { + $group = $tab_id; + $suffix = ''; + } else { + $group = 'itil'; + $suffix = '_' . $tab_id; + } $sections = []; foreach (self::getScreenSections()[$group] as $section) { From 49b92ed8c3bfed108f5a8733473041eae6078174 Mon Sep 17 00:00:00 2001 From: Lainow Date: Mon, 28 Sep 2026 09:45:02 +0200 Subject: [PATCH 2/9] Fix lints --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfea78..a6a4b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,3 +23,4 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed the issue where a ticket could be solved without a solution ## [1.0.0-rc1] + From b00c094d910d944de9e357e162cf50dc71bc3483 Mon Sep 17 00:00:00 2001 From: Samuel Launay <107540223+Lainow@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:00:17 +0200 Subject: [PATCH 3/9] Feat escalate process (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement base of escalation process * Fix used groups filter * Implement tests * Fix lints * Fix lints * Apply suggestion from @MyvTsv * Update src/Escalation.php Co-authored-by: Langlois Gaëtan <64356364+MyvTsv@users.noreply.github.com> * Fix lints --------- Co-authored-by: Langlois Gaëtan <64356364+MyvTsv@users.noreply.github.com> --- CHANGELOG.md | 1 + ajax/escalation_form.php | 50 +++ front/escalation.form.php | 54 +++ hook.php | 3 + public/css/moreoptions.scss | 85 +++++ setup.php | 15 +- src/Config.php | 6 +- src/Controller.php | 16 +- src/Escalation.php | 454 ++++++++++++++++++++++++ templates/escalation_button.html.twig | 73 ++++ templates/escalation_form.html.twig | 127 +++++++ templates/escalation_timeline.html.twig | 63 ++++ tests/Units/EscalationTest.php | 263 ++++++++++++++ 13 files changed, 1207 insertions(+), 3 deletions(-) create mode 100644 ajax/escalation_form.php create mode 100644 front/escalation.form.php create mode 100644 src/Escalation.php create mode 100644 templates/escalation_button.html.twig create mode 100644 templates/escalation_form.html.twig create mode 100644 templates/escalation_timeline.html.twig create mode 100644 tests/Units/EscalationTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a6a4b9f..a59cb6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Add - Add rector config +- Implementation of the basic concept of escalation ## [1.0.0-rc2] diff --git a/ajax/escalation_form.php b/ajax/escalation_form.php new file mode 100644 index 0000000..749c93b --- /dev/null +++ b/ajax/escalation_form.php @@ -0,0 +1,50 @@ +getFromDB((int) ($_GET['items_id'] ?? 0)) + || !$item->canAssign() + || !Escalation::isEnabledFor($item) +) { + throw new AccessDeniedHttpException(); +} + +Escalation::showEscalationForm($item); diff --git a/front/escalation.form.php b/front/escalation.form.php new file mode 100644 index 0000000..2f03067 --- /dev/null +++ b/front/escalation.form.php @@ -0,0 +1,54 @@ +getFromDB((int) ($_POST['items_id'] ?? 0)) + || !$item->canAssign() + || !Escalation::isEnabledFor($item) + ) { + throw new AccessDeniedHttpException(); + } + + (new Escalation())->add($_POST); +} + +Html::back(); diff --git a/hook.php b/hook.php index b721a23..676ccbb 100644 --- a/hook.php +++ b/hook.php @@ -34,12 +34,14 @@ declare(strict_types=1); use GlpiPlugin\Moreoptions\Config; +use GlpiPlugin\Moreoptions\Escalation; function plugin_moreoptions_install(): bool { $migration = new Migration(PLUGIN_MOREOPTIONS_VERSION); Config::install($migration); + Escalation::install($migration); $migration->executeMigration(); return true; } @@ -49,6 +51,7 @@ function plugin_moreoptions_uninstall(): bool $migration = new Migration(PLUGIN_MOREOPTIONS_VERSION); Config::uninstall($migration); + Escalation::uninstall($migration); return true; } diff --git a/public/css/moreoptions.scss b/public/css/moreoptions.scss index 276327f..646d9cd 100644 --- a/public/css/moreoptions.scss +++ b/public/css/moreoptions.scss @@ -111,3 +111,88 @@ } } } + +:root { + --mo-escalation-color: #ffdddd80; + + --mo-escalation-background: var(--mo-escalation-color); + --mo-escalation-border: color-mix(in srgb, var(--mo-escalation-color), #af2020 30%); +} + +// "Escalate" button, next to the "Assigned to" field (see escalation_button.html.twig). +.moreoptions-escalate-btn, +.moreoptions-escalate-btn:focus { + color: var(--mo-escalation-border) !important; + border-color: var(--mo-escalation-border) !important; +} + +.moreoptions-escalate-btn:hover { + background-color: var(--mo-escalation-border) !important; + border-color: var(--mo-escalation-border) !important; + color: white !important; +} + +// Escalation entries in the timeline (see Escalation::showInTimeline()): a single line, the same +// way GLPI core displays pending reason reminders (`.ITILReminder` in _timeline.scss). +.timeline-item.moreoptions-escalation { + width: 100%; + + .user-part { + display: none !important; + } + + .content-part { + flex-direction: row-reverse !important; + justify-content: flex-end; + margin-left: 75px; + margin-right: 83px; + + .timeline-content { + border: 0; + box-shadow: none; + margin-top: 0 !important; + flex-grow: 0 !important; + background: transparent; + + .card-body { + padding: 0; + display: flex; + flex-direction: row-reverse; + + .timeline-header { + margin-left: 1rem; + margin-top: 0; + margin-bottom: 0; + } + } + } + + .user_link { + display: none !important; + } + + .itil-date-creation { + margin-left: 1rem !important; + } + } + + // "Internal" icon, moved into the "Created: ... by ..." badge (see escalation_timeline.html.twig). + .moreoptions-escalation-separator { + opacity: 0.4; + } + + .moreoptions-escalation-group { + color: inherit; + font-weight: normal; + background-color: var(--mo-escalation-background); + border: 1px solid var(--mo-escalation-border); + + a { + color: inherit; + + &:hover { + text-decoration: underline; + } + } + } +} diff --git a/setup.php b/setup.php index bd1a412..96afaca 100644 --- a/setup.php +++ b/setup.php @@ -36,6 +36,7 @@ use Glpi\Plugin\Hooks; use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Controller; +use GlpiPlugin\Moreoptions\Escalation; /** @phpstan-ignore theCodingMachineSafe.function (safe to assume this isn't already defined) */ define('PLUGIN_MOREOPTIONS_VERSION', '1.0.0-rc2'); @@ -81,10 +82,22 @@ function plugin_init_moreoptions(): void // Both hooks below are called by GLPI core with an array of parameters (not an item // instance), so they must be registered without an itemtype key: the callback filters // on $params['item'] itself. - $PLUGIN_HOOKS[Hooks::TIMELINE_ACTIONS]['moreoptions'] = Controller::showSolutionRequirementsWarning(...); + $PLUGIN_HOOKS[Hooks::TIMELINE_ACTIONS]['moreoptions'] = Controller::showTimelineActions(...); + $PLUGIN_HOOKS[Hooks::POST_ITEM_FORM]['moreoptions'] = Controller::markMandatoryTaskFields(...); + $PLUGIN_HOOKS[Hooks::TIMELINE_ITEMS]['moreoptions'] = Escalation::showInTimeline(...); + + // Group links added with `_plugin_moreoptions_escalade => true` are escalations. + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Group_Ticket::class] = Escalation::escalate(...); + + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Change_Group::class] = Escalation::escalate(...); + + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Group_Problem::class] = Escalation::escalate(...); + + $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['moreoptions'][Ticket::class] = Controller::beforeCloseITILObject(...); + $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['moreoptions'][Ticket::class] = Controller::beforeCloseITILObject(...); $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['moreoptions'][Change::class] = Controller::beforeCloseITILObject(...); diff --git a/src/Config.php b/src/Config.php index 442eed7..458227c 100644 --- a/src/Config.php +++ b/src/Config.php @@ -402,7 +402,9 @@ private static function getScreenSections(): array [ 'title' => __('Escalate', 'moreoptions'), 'icon' => 'ti-arrow-up', - 'rows' => [], + 'rows' => [ + ['key' => 'escalate_is_active', 'kind' => 'yes_no', 'label' => __('Activate escalation', 'moreoptions')], + ], ], ], ]; @@ -574,6 +576,7 @@ public static function install(Migration $migration): void `assign_technician_from_task_ticket` tinyint NOT NULL DEFAULT '0', `assign_technician_from_task_change` tinyint NOT NULL DEFAULT '0', `assign_technician_from_task_problem` tinyint NOT NULL DEFAULT '0', + `escalate_is_active` tinyint NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `entities_id` (`entities_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; @@ -592,6 +595,7 @@ public static function install(Migration $migration): void 'assign_technician_from_task_ticket', 'assign_technician_from_task_change', 'assign_technician_from_task_problem', + 'escalate_is_active', ] as $field ) { if (!$DB->fieldExists($table, $field)) { diff --git a/src/Controller.php b/src/Controller.php index 1e9490b..6377dfb 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -52,6 +52,7 @@ use Glpi\Application\View\TemplateRenderer; use GlpiPlugin\Moreoptions\Config; use Group; +use GlpiPlugin\Moreoptions\Escalation; use Group_Item; use Group_Problem; use Group_Ticket; @@ -525,7 +526,20 @@ public static function requireFieldsToClose(CommonDBTM $item, bool $is_solution } /** - * Hooked on {@link \Glpi\Plugin\Hooks::TIMELINE_ACTIONS}. Renders, into the ticket/change/ + * Hooked on {@link \Glpi\Plugin\Hooks::TIMELINE_ACTIONS}, which takes a single callback per + * plugin: renders everything MoreOptions adds to the ticket/change/problem timeline footer. + * + * @param array $params + */ + public static function showTimelineActions(array $params): void + { + self::showSolutionRequirementsWarning($params); + Escalation::showEscalateButton($params); + Escalation::showTimelineScripts($params); + } + + /** + * Called from {@see self::showTimelineActions()}. Renders, into the ticket/change/ * problem timeline footer, a script that mutes the "Add a solution" action, adds a lock icon * to it, and attaches a popover listing the missing fields, as soon as one of the fields * required to close the item (technician, group, category, location...) is missing. diff --git a/src/Escalation.php b/src/Escalation.php new file mode 100644 index 0000000..eb1290e --- /dev/null +++ b/src/Escalation.php @@ -0,0 +1,454 @@ +> + */ + private static function getSupportedItemtypes(): array + { + return [Ticket::class, Change::class, Problem::class]; + } + + public static function isEnabledFor(CommonITILObject $item): bool + { + if (!in_array($item::class, self::getSupportedItemtypes(), true)) { + return false; + } + + $config = Config::getConfig((int) $item->fields['entities_id']); + + return (int) ($config->fields['escalate_is_active'] ?? 0) === 1; + } + + /** + * Hooked on {@link \Glpi\Plugin\Hooks::TIMELINE_ITEMS}. Adds the escalations of the item + * to its timeline. + * + * @param array $params Parameters passed by the hook (keys: item, timeline) + */ + public static function showInTimeline(array $params): void + { + if (!isset($params['item'], $params['timeline'])) { + return; + } + + $item = $params['item']; + if (!$item instanceof CommonITILObject || !self::isEnabledFor($item)) { + return; + } + + /** @var array $timeline */ + $timeline = &$params['timeline']; + $can_see_private = Session::haveRight('followup', ITILFollowup::SEEPRIVATE); + + $criterias = [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + ]; + + if (!$can_see_private) { + $criterias['is_private'] = 0; + } + + $escalations = (new self())->find($criterias); + + foreach ($escalations as $row) { + $timeline['MoreoptionsEscalation_' . $row['id']] = [ + 'type' => self::getType(), + 'class' => 'moreoptions-escalation', + 'item' => [ + 'id' => $row['id'], + 'content' => self::getTimelineContent($row), + 'is_content_safe' => true, + 'users_id' => $row['users_id'], + 'can_edit' => false, + 'timeline_position' => CommonITILObject::TIMELINE_LEFT, + 'date_creation' => $row['date_creation'], + 'date_mod' => $row['date_mod'], + 'is_private' => $row['is_private'], + ], + ]; + } + } + + /** + * The one-line summary shown in the timeline, in the manner of pending reason reminders: + * "Escalate from to ", or "Escalate to " when no + * group was assigned before the escalation. Each group links to its form. + * + * @param array $row + */ + private static function getTimelineContent(array $row): string + { + $can_view_groups = Group::canView(); + $badge = static function (int $groups_id) use ($can_view_groups): string { + $name = htmlescape(Dropdown::getDropdownName(Group::getTable(), $groups_id)); + if ($can_view_groups) { + $name = sprintf('%s', htmlescape(Group::getFormURLWithID($groups_id)), $name); + } + + return ' ' . $name . ''; + }; + + $target = $badge((int) $row['groups_id']); + $sources = array_map($badge, self::getSourceGroupIds($row)); + $text = $sources !== [] + ? sprintf(__s('Escalate from %1$s to %2$s', 'moreoptions'), implode(' ', $sources), $target) + : sprintf(__s('Escalate to %s', 'moreoptions'), $target); + + $content = '' + . '' + . $text; + + if (!empty($row['content'])) { + // Inline: the comment as plain text on a single line, cut with "..." when too long. + // Tooltip: the whole comment, with its formatting. + $excerpt = trim((string) preg_replace('/\s+/', ' ', RichText::getTextFromHtml($row['content'], false, true))); + if (mb_strlen($excerpt) > self::TIMELINE_EXCERPT_LENGTH) { + $excerpt = rtrim(mb_substr($excerpt, 0, self::TIMELINE_EXCERPT_LENGTH)) . '...'; + } + + $content .= sprintf( + '%s', + htmlescape(RichText::getSafeHtml($row['content'])), + ' (' . htmlescape($excerpt) . ')', + ); + } + + return $content . ''; + } + + /** + * @param array $row + * @return array + */ + private static function getSourceGroupIds(array $row): array + { + $groups_ids = json_decode((string) ($row['groups_ids_source'] ?? ''), true); + + return is_array($groups_ids) ? array_map(intval(...), $groups_ids) : []; + } + + /** + * The author is always the current user, and the source groups are the groups assigned to + * the item before the escalation. Escalating to a group already assigned is refused. + * + * @param array $input + * @return array|false + */ + public function prepareInputForAdd($input) + { + $item = getItemForItemtype($input['itemtype'] ?? ''); + if (!$item instanceof CommonITILObject || !$item->getFromDB((int) ($input['items_id'] ?? 0))) { + return false; + } + + $input['users_id'] = Session::getLoginUserID(); + + $groups_ids_source = []; + $group_link = getItemForItemtype($item->grouplinkclass); + if ($group_link instanceof CommonDBTM) { + foreach ( + $group_link->find([ + $item->getForeignKeyField() => $item->getID(), + 'type' => CommonITILActor::ASSIGN, + ]) as $assigned + ) { + $groups_ids_source[] = (int) $assigned['groups_id']; + } + } + + if (in_array((int) ($input['groups_id'] ?? 0), $groups_ids_source, true)) { + Session::addMessageAfterRedirect( + __s('This group is already assigned.', 'moreoptions'), + false, + ERROR, + ); + return false; + } + + $input['groups_ids_source'] = json_encode($groups_ids_source); + + return $input; + } + + /** + * Applies the escalation to the escalated item, through the group link hook (see + * self::escalate()): assigns the target group with `_plugin_moreoptions_escalade`, which drops + * the other assigned groups. + */ + public function post_addItem() + { + $item = getItemForItemtype($this->fields['itemtype']); + if (!$item instanceof CommonITILObject) { + return; + } + + $group_link = getItemForItemtype($item->grouplinkclass); + if (!$group_link instanceof CommonITILActor) { + return; + } + + $group_link->add([ + $item->getForeignKeyField() => (int) $this->fields['items_id'], + 'groups_id' => (int) $this->fields['groups_id'], + 'type' => CommonITILActor::ASSIGN, + '_plugin_moreoptions_escalade' => true, + ]); + } + + /** + * Called from the {@link \Glpi\Plugin\Hooks::TIMELINE_ACTIONS} hook (see + * Controller::showTimelineActions()). Renders the script that adds a small "Escalate" button + * next to the "Assigned to" label, opening the escalation form in a modal. + * + * @param array $params + */ + public static function showEscalateButton(array $params): void + { + $item = $params['item'] ?? null; + if (!$item instanceof CommonITILObject || $item->isNewItem() || !$item->canAssign() || !self::isEnabledFor($item)) { + return; + } + + TemplateRenderer::getInstance()->display('@moreoptions/escalation_button.html.twig', [ + 'marker_id' => 'moreoptions-escalate-' . $item->getType() . '-' . $item->getID(), + 'itemtype' => $item->getType(), + 'items_id' => $item->getID(), + ]); + } + + /** + * Called from the {@link \Glpi\Plugin\Hooks::TIMELINE_ACTIONS} hook (see + * Controller::showTimelineActions()). Renders the script that moves the "internal" icon of + * escalation entries into their "Created: ... by ..." badge. + * + * @param array $params + */ + public static function showTimelineScripts(array $params): void + { + $item = $params['item'] ?? null; + if (!$item instanceof CommonITILObject || $item->isNewItem() || !self::isEnabledFor($item)) { + return; + } + + TemplateRenderer::getInstance()->display('@moreoptions/escalation_timeline.html.twig', [ + 'marker_id' => 'moreoptions-escalation-timeline-' . $item->getType() . '-' . $item->getID(), + ]); + } + + /** + * Renders the escalation form, loaded in the modal opened by the "Escalate" button (see + * ajax/escalation_form.php). + */ + public static function showEscalationForm(CommonITILObject $item): void + { + switch ($item::class) { + case Ticket::class: + $groups = new Group_Ticket(); + break; + case Change::class: + $groups = new Change_Group(); + break; + case Problem::class: + $groups = new Group_Problem(); + break; + default: + return; + } + + $groups = $groups->find([strtolower($item::class) . 's_id' => $item->getID(), 'type' => CommonITILActor::ASSIGN]); + foreach ($groups as $key => $row) { + $groups_used[$key] = (int) $row['groups_id']; + } + + TemplateRenderer::getInstance()->display('@moreoptions/escalation_form.html.twig', [ + 'item' => $item, + 'groups_used' => $groups_used ?? [], + ]); + } + + /** + * Hooked on {@link \Glpi\Plugin\Hooks::ITEM_ADD} for Group_Ticket, Change_Group and + * Group_Problem. Only group links added with `_plugin_moreoptions_escalade => true` in their + * input are escalations: the new group then replaces the previously assigned ones. + */ + public static function escalate(CommonITILActor $group_link): void + { + if ( + !is_array($group_link->input) + || !($group_link->input['_plugin_moreoptions_escalade'] ?? false) + || (int) $group_link->fields['type'] !== CommonITILActor::ASSIGN + ) { + return; + } + + self::keepOnlyAssignedGroup($group_link); + } + + /** + * Keep only the given group assigned to its item: drop the other assigned groups. + */ + private static function keepOnlyAssignedGroup(CommonITILActor $group_link): void + { + $items_id_field = $group_link::$items_id_1; + + $previous_links = $group_link->find([ + $items_id_field => (int) $group_link->fields[$items_id_field], + 'type' => CommonITILActor::ASSIGN, + 'NOT' => ['id' => $group_link->getID()], + ]); + foreach ($previous_links as $previous_link) { + (new ($group_link::class)())->delete(['id' => $previous_link['id']]); + } + } + + public static function install(Migration $migration): void + { + /** @var DBmysql $DB */ + global $DB; + + $table = self::getTable(); + if (!$DB->tableExists($table)) { + $migration->displayMessage('Installing ' . $table); + $query = "CREATE TABLE IF NOT EXISTS `{$table}` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `itemtype` varchar(100) NOT NULL DEFAULT '', + `items_id` int unsigned NOT NULL DEFAULT '0', + `users_id` int unsigned NOT NULL DEFAULT '0', + `groups_ids_source` text, + `groups_id` int unsigned NOT NULL DEFAULT '0', + `content` longtext, + `date_creation` timestamp NULL DEFAULT NULL, + `date_mod` timestamp NULL DEFAULT NULL, + `is_private` tinyint NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `item` (`itemtype`, `items_id`), + KEY `users_id` (`users_id`), + KEY `groups_id` (`groups_id`), + KEY `date_creation` (`date_creation`), + KEY `date_mod` (`date_mod`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + "; + $DB->doQuery($query); + } + + // Source groups, formerly a single `groups_id_source`, are now a JSON list. + if (!$DB->fieldExists($table, 'groups_ids_source')) { + $migration->addField($table, 'groups_ids_source', 'text', ['after' => 'users_id']); + $migration->migrationOneTable($table); + + if ($DB->fieldExists($table, 'groups_id_source')) { + foreach ($DB->request(['FROM' => $table, 'WHERE' => ['groups_id_source' => ['>', 0]]]) as $row) { + $DB->update( + $table, + ['groups_ids_source' => json_encode([(int) $row['groups_id_source']])], + ['id' => $row['id']], + ); + } + } + } + + if ($DB->fieldExists($table, 'groups_id_source')) { + $migration->dropKey($table, 'groups_id_source'); + $migration->dropField($table, 'groups_id_source'); + } + + if (!$DB->fieldExists($table, 'is_private')) { + $migration->addField($table, 'is_private', 'bool', ['value' => '0']); + } + + $migration->executeMigration(); + } + + public static function uninstall(Migration $migration): void + { + /** @var DBmysql $DB */ + global $DB; + + $table = self::getTable(); + if ($DB->tableExists($table)) { + $DB->doQuery(sprintf('DROP TABLE IF EXISTS `%s`', $table)); + } + } +} diff --git a/templates/escalation_button.html.twig b/templates/escalation_button.html.twig new file mode 100644 index 0000000..4f625b2 --- /dev/null +++ b/templates/escalation_button.html.twig @@ -0,0 +1,73 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # ------------------------------------------------------------------------- + #} + +{# + # Rendered through the core `Hooks::TIMELINE_ACTIONS` hook (see Escalation::showEscalateButton()). + # The "Assigned to" field is rendered by GLPI core, so this only drops an invisible anchor + + # script that adds a small "Escalate" button next to its label. The button opens the escalation + # form in a modal (see ajax/escalation_form.php). + #} + + diff --git a/templates/escalation_form.html.twig b/templates/escalation_form.html.twig new file mode 100644 index 0000000..8af422a --- /dev/null +++ b/templates/escalation_form.html.twig @@ -0,0 +1,127 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # ------------------------------------------------------------------------- + #} + +{# + # Escalation form, loaded in the modal opened by the "Escalate" button next to the "Assigned to" + # field (see escalation_button.html.twig and ajax/escalation_form.php). + #} +{% import 'components/form/fields_macros.html.twig' as fields %} + +{% set rand = random() %} + +
+
+ + + + + +
+
+ {{ fields.textareaField( + 'content', + '', + '', + { + 'full_width': true, + 'no_label': true, + 'enable_richtext': true, + 'entities_id': item.fields['entities_id'], + 'rand': rand, + 'aria_label': __('Comment', 'moreoptions'), + } + ) }} +
+ +
+
+ {% set group_lbl %} + + {% endset %} + {{ fields.dropdownField( + 'Group', + 'groups_id', + 0, + group_lbl, + { + 'full_width': true, + 'icon_label': true, + 'required': true, + 'entity': item.fields['entities_id'], + 'condition': {'is_assign': 1}, + 'rand': rand, + 'aria_label': 'Group'|itemtype_name, + } + ) }} + + {% set observer_lbl %} + + {% endset %} + {{ fields.sliderField( + 'add_me_as_observer', + 0, + observer_lbl, + { + 'full_width': true, + 'icon_label': true, + 'rand': rand, + } + ) }} + + {% set private_label %} + + {% endset %} + + {{ fields.sliderField( + 'is_private', + 1, + private_label, + { + 'full_width': true, + 'icon_label': true, + 'rand': rand, + } + ) }} +
+
+
+ +
+ +
+
+
diff --git a/templates/escalation_timeline.html.twig b/templates/escalation_timeline.html.twig new file mode 100644 index 0000000..e9161c6 --- /dev/null +++ b/templates/escalation_timeline.html.twig @@ -0,0 +1,63 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # ------------------------------------------------------------------------- + #} + +{# + # Rendered through the core `Hooks::TIMELINE_ACTIONS` hook (see Escalation::showTimelineScripts()). + # The header of timeline entries is rendered by GLPI core, so this only drops an invisible anchor + + # script that moves the "internal" icon of escalation entries into their "Created: ... by ..." + # badge, after a small separator. + #} + + diff --git a/tests/Units/EscalationTest.php b/tests/Units/EscalationTest.php new file mode 100644 index 0000000..53ae250 --- /dev/null +++ b/tests/Units/EscalationTest.php @@ -0,0 +1,263 @@ +, int}> + */ + public static function escalationProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype . ' without group before escalation' => [$itemtype, 0]; + yield $itemtype . ' with one group before escalation' => [$itemtype, 1]; + yield $itemtype . ' with two groups before escalation' => [$itemtype, 2]; + } + } + + /** + * @param class-string $itemtype + */ + #[DataProvider('escalationProvider')] + public function testEscalation(string $itemtype, int $nb_source_groups): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + // Groups assigned to the item before the escalation + $source_groups = []; + for ($i = 1; $i <= $nb_source_groups; $i++) { + $source_groups[] = $this->createGroup($entities_id, 'Source group ' . $i); + } + + foreach ($source_groups as $group) { + $this->createItem($item->grouplinkclass, [ + $item->getForeignKeyField() => $item->getID(), + 'groups_id' => $group->getID(), + 'type' => CommonITILActor::ASSIGN, + ]); + } + + $this->assertSame($this->getIdsOf($source_groups), $this->getAssignedGroupIds($item)); + + // Escalate to a new group + $target_group = $this->createGroup($entities_id, 'Target group'); + $escalation = $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $target_group->getID(), + 'content' => 'Escalation comment', + ]); + + // The escalation keeps track of its author and of the previously assigned groups + $this->assertSame(Session::getLoginUserID(), (int) $escalation->fields['users_id']); + $this->assertSame( + $this->getIdsOf($source_groups), + json_decode($escalation->fields['groups_ids_source'], true), + ); + + // Only the target group remains assigned to the item + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + + // The escalation is shown in the item timeline + $this->assertTrue($item->getFromDB($item->getID())); + /** @var array}> $timeline_entries */ + $timeline_entries = array_values(array_filter( + $item->getTimelineItems(), + static fn(array $entry): bool => $entry['type'] === Escalation::class, + )); + $this->assertCount(1, $timeline_entries); + + $entry = $timeline_entries[0]['item']; + $this->assertSame($escalation->getID(), (int) $entry['id']); + $this->assertSame(Session::getLoginUserID(), (int) $entry['users_id']); + $this->assertSame(CommonITILObject::TIMELINE_LEFT, $entry['timeline_position']); + + $content = $entry['content']; + $this->assertStringContainsString('Target group', $content); + $this->assertStringContainsString('Escalation comment', $content); + if ($nb_source_groups === 0) { + $this->assertStringContainsString('Escalate to', $content); + $this->assertStringNotContainsString('Escalate from', $content); + } else { + $this->assertStringContainsString('Escalate from', $content); + } + + foreach ($source_groups as $group) { + $this->assertStringContainsString($group->fields['name'], $content); + } + } + + /** + * @return iterable}> + */ + public static function itemtypeProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype => [$itemtype]; + } + } + + /** + * @param class-string $itemtype + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationToAlreadyAssignedGroupIsRefused(string $itemtype): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + $assigned_groups = [ + $this->createGroup($entities_id, 'Assigned group 1'), + $this->createGroup($entities_id, 'Assigned group 2'), + ]; + foreach ($assigned_groups as $group) { + $this->createItem($item->grouplinkclass, [ + $item->getForeignKeyField() => $item->getID(), + 'groups_id' => $group->getID(), + 'type' => CommonITILActor::ASSIGN, + ]); + } + + $escalation = new Escalation(); + $this->assertFalse($escalation->add([ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $assigned_groups[0]->getID(), + ])); + $this->hasSessionMessages(ERROR, ['This group is already assigned.']); + + // Nothing changed: no escalation, and the assigned groups are kept + $this->assertSame(0, countElementsInTable(Escalation::getTable(), [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + ])); + $this->assertSame($this->getIdsOf($assigned_groups), $this->getAssignedGroupIds($item)); + + // Nothing is added to the timeline + $this->assertTrue($item->getFromDB($item->getID())); + $this->assertSame([], array_values(array_filter( + $item->getTimelineItems(), + static fn(array $entry): bool => $entry['type'] === Escalation::class, + ))); + } + + /** + * Enable the escalation option for the given entity. + */ + private function enableEscalation(int $entities_id): void + { + $config = Config::getConfig($entities_id, false); + if ($config->isNewItem()) { + $this->createTestConfig([ + 'entities_id' => $entities_id, + 'escalate_is_active' => 1, + ]); + } else { + $this->updateTestConfig($config, ['escalate_is_active' => 1]); + } + } + + private function createGroup(int $entities_id, string $name): Group + { + $group = $this->createItem(Group::class, [ + 'name' => $name, + 'entities_id' => $entities_id, + 'is_recursive' => 1, + 'is_assign' => 1, + ]); + $this->assertInstanceOf(Group::class, $group); + + return $group; + } + + /** + * @return array + */ + private function getAssignedGroupIds(CommonITILObject $item): array + { + $group_link = getItemForItemtype($item->grouplinkclass); + $this->assertInstanceOf(CommonITILActor::class, $group_link); + + return array_map( + static fn(array $row): int => (int) $row['groups_id'], + array_values($group_link->find([ + $item->getForeignKeyField() => $item->getID(), + 'type' => CommonITILActor::ASSIGN, + ], ['id ASC'])), + ); + } + + /** + * @param array $groups + * @return array + */ + private function getIdsOf(array $groups): array + { + return array_map(static fn(Group $group): int => $group->getID(), $groups); + } +} From c7ba0fdb1cc3a27135d051ebb982476ff7414a7b Mon Sep 17 00:00:00 2001 From: Lainow Date: Mon, 28 Sep 2026 14:49:24 +0200 Subject: [PATCH 4/9] Fix UI --- public/css/moreoptions.scss | 100 +++++++++++++++++------- src/Escalation.php | 64 +++++++-------- templates/escalation_timeline.html.twig | 40 +++++++--- 3 files changed, 130 insertions(+), 74 deletions(-) diff --git a/public/css/moreoptions.scss b/public/css/moreoptions.scss index 646d9cd..5d04a19 100644 --- a/public/css/moreoptions.scss +++ b/public/css/moreoptions.scss @@ -113,10 +113,10 @@ } :root { - --mo-escalation-color: #ffdddd80; + --mo-escalation-color: #fff0f0ca; --mo-escalation-background: var(--mo-escalation-color); - --mo-escalation-border: color-mix(in srgb, var(--mo-escalation-color), #af2020 30%); + --mo-escalation-border: color-mix(in srgb, var(--mo-escalation-color), #a81313 50%); } // "Escalate" button, next to the "Assigned to" field (see escalation_button.html.twig). @@ -132,8 +132,10 @@ color: white !important; } -// Escalation entries in the timeline (see Escalation::showInTimeline()): a single line, the same -// way GLPI core displays pending reason reminders (`.ITILReminder` in _timeline.scss). +// Escalation entries in the timeline (see Escalation::getTimelineContent() and +// escalation_timeline.html.twig): a centered card with a dashed border, holding the +// " | -> " summary next to the "Created: ... by ..." badge, +// then the escalation comment, which can be collapsed. .timeline-item.moreoptions-escalation { width: 100%; @@ -142,50 +144,72 @@ } .content-part { - flex-direction: row-reverse !important; - justify-content: flex-end; + align-items: center; margin-left: 75px; margin-right: 83px; .timeline-content { - border: 0; - box-shadow: none; - margin-top: 0 !important; flex-grow: 0 !important; - background: transparent; + // Core stretches timeline entries to the full width: fit the content instead, so a + // collapsed (or comment-less) entry is no wider than its header line. + min-width: 0; + max-width: 100%; + background: var(--mo-escalation-background); + // Core squares the corner the (hidden) speech bubble arrow points to. + border-radius: var(--tblr-border-radius-lg) !important; + // Also replaces the thicker dashed border of "internal" entries (`.private-item`). + border: 1px dashed var(--mo-escalation-border); + box-shadow: none; + + // Speech bubble arrow pointing to the (hidden) avatar + &::before, + &::after { + display: none; + } .card-body { - padding: 0; - display: flex; - flex-direction: row-reverse; - - .timeline-header { - margin-left: 1rem; - margin-top: 0; - margin-bottom: 0; - } + padding: 0.5rem 1rem !important; } } + } - .user_link { - display: none !important; - } + .timeline-header { + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin: 0; + } - .itil-date-creation { - margin-left: 1rem !important; - } + .timeline-item-buttons { + align-items: center; + gap: 0.5rem; + margin-left: 0 !important; } - // "Internal" icon, moved into the "Created: ... by ..." badge (see escalation_timeline.html.twig). .moreoptions-escalation-separator { opacity: 0.4; } + .moreoptions-escalation-summary { + display: inline-flex; + align-items: center; + gap: 0.5rem; + color: var(--mo-escalation-border); + } + + .moreoptions-escalation-groups { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + font-style: italic; + } + .moreoptions-escalation-group { - color: inherit; - font-weight: normal; - background-color: var(--mo-escalation-background); - border: 1px solid var(--mo-escalation-border); + display: inline-flex; + align-items: center; + gap: 0.25rem; a { color: inherit; @@ -195,4 +219,20 @@ } } } + + .moreoptions-escalation-toggle { + padding: 0 0.25rem; + + i { + transition: transform 0.2s; + } + + &.collapsed i { + transform: rotate(180deg); + } + } + + .moreoptions-escalation-comment { + margin-top: 0.75rem; + } } diff --git a/src/Escalation.php b/src/Escalation.php index eb1290e..534bc6b 100644 --- a/src/Escalation.php +++ b/src/Escalation.php @@ -54,7 +54,6 @@ use function Safe\json_decode; use function Safe\json_encode; -use function Safe\preg_replace; /** * An escalation of a ticket / change / problem, shown as its own entry in the @@ -66,11 +65,6 @@ class Escalation extends CommonDBTM public static $rightname = 'ticket'; - /** - * Maximum length of the comment shown inline in the timeline, before it gets cut with "...". - */ - private const TIMELINE_EXCERPT_LENGTH = 50; - public static function getTypeName($nb = 0): string { return _n('Escalation', 'Escalations', $nb, 'moreoptions'); @@ -152,50 +146,56 @@ public static function showInTimeline(array $params): void } /** - * The one-line summary shown in the timeline, in the manner of pending reason reminders: - * "Escalate from to ", or "Escalate to " when no - * group was assigned before the escalation. Each group links to its form. + * The timeline entry of an escalation: a header line " | -> " + * (see escalation_timeline.html.twig, which puts it next to the "Created: ... by ..." badge), + * followed by the escalation comment, if any, which can be collapsed. * * @param array $row */ private static function getTimelineContent(array $row): string { $can_view_groups = Group::canView(); - $badge = static function (int $groups_id) use ($can_view_groups): string { + $group_link = static function (int $groups_id) use ($can_view_groups): string { $name = htmlescape(Dropdown::getDropdownName(Group::getTable(), $groups_id)); if ($can_view_groups) { $name = sprintf('%s', htmlescape(Group::getFormURLWithID($groups_id)), $name); } - return ' ' . $name . ''; + return '' . $name . ''; }; - $target = $badge((int) $row['groups_id']); - $sources = array_map($badge, self::getSourceGroupIds($row)); - $text = $sources !== [] - ? sprintf(__s('Escalate from %1$s to %2$s', 'moreoptions'), implode(' ', $sources), $target) - : sprintf(__s('Escalate to %s', 'moreoptions'), $target); + $target = $group_link((int) $row['groups_id']); + $sources = array_map($group_link, self::getSourceGroupIds($row)); - $content = '' - . '' - . $text; + // The same as a sentence, as the icon tooltip. Already escaped: the group names are. + $sentence = strip_tags($sources !== [] + ? sprintf(__s('Escalate from %1$s to %2$s', 'moreoptions'), implode(', ', $sources), $target) + : sprintf(__s('Escalate to %s', 'moreoptions'), $target)); - if (!empty($row['content'])) { - // Inline: the comment as plain text on a single line, cut with "..." when too long. - // Tooltip: the whole comment, with its formatting. - $excerpt = trim((string) preg_replace('/\s+/', ' ', RichText::getTextFromHtml($row['content'], false, true))); - if (mb_strlen($excerpt) > self::TIMELINE_EXCERPT_LENGTH) { - $excerpt = rtrim(mb_substr($excerpt, 0, self::TIMELINE_EXCERPT_LENGTH)) . '...'; - } + $content = '
' + . '' + . '' + . '' + . implode('', $sources) + . '' + . $target + . '' + . '
'; + if (!empty($row['content'])) { + $comment_id = 'moreoptions-escalation-comment-' . (int) $row['id']; $content .= sprintf( - '%s', - htmlescape(RichText::getSafeHtml($row['content'])), - ' (' . htmlescape($excerpt) . ')', + '' + . '
%3$s
', + htmlescape($comment_id), + htmlescape(__('Show / hide the comment', 'moreoptions')), + RichText::getEnhancedHtml($row['content']), ); } - return $content . '
'; + return $content; } /** @@ -300,8 +300,8 @@ public static function showEscalateButton(array $params): void /** * Called from the {@link \Glpi\Plugin\Hooks::TIMELINE_ACTIONS} hook (see - * Controller::showTimelineActions()). Renders the script that moves the "internal" icon of - * escalation entries into their "Created: ... by ..." badge. + * Controller::showTimelineActions()). Renders the script that lays out the header of escalation + * entries (summary, "Created: ... by ..." badge, collapse button, "internal" icon). * * @param array $params */ diff --git a/templates/escalation_timeline.html.twig b/templates/escalation_timeline.html.twig index e9161c6..2d27192 100644 --- a/templates/escalation_timeline.html.twig +++ b/templates/escalation_timeline.html.twig @@ -32,8 +32,10 @@ {# # Rendered through the core `Hooks::TIMELINE_ACTIONS` hook (see Escalation::showTimelineScripts()). # The header of timeline entries is rendered by GLPI core, so this only drops an invisible anchor + - # script that moves the "internal" icon of escalation entries into their "Created: ... by ..." - # badge, after a small separator. + # script that, for each escalation entry (see Escalation::getTimelineContent()): + # - moves the " | -> " summary before the "Created: ... by ..." badge, + # - moves the button collapsing the comment next to the badge, + # - puts a small separator before the "internal" icon. #} From 4fe5c100ebcbbf24d2dea56a7e0409d867f773f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Langlois=20Ga=C3=ABtan?= <64356364+MyvTsv@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:10:30 +0200 Subject: [PATCH 5/9] feat(group): add escalation links skeleton between groups (#20) * feat(group): add escalation links skeleton between groups * Update CHANGELOG.md --- CHANGELOG.md | 2 +- hook.php | 3 + setup.php | 3 + src/Group_Link.php | 150 ++++++++++++++++++++++ src/LinkStrategy/AbstractLinkStrategy.php | 74 +++++++++++ src/LinkStrategy/BasicLink.php | 51 ++++++++ src/LinkStrategy/InheritedLink.php | 56 ++++++++ src/LinkStrategy/LinkStrategyEnum.php | 78 +++++++++++ src/LinkStrategy/NoneLink.php | 56 ++++++++ templates/group_link.html.twig | 30 +++++ 10 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 src/Group_Link.php create mode 100644 src/LinkStrategy/AbstractLinkStrategy.php create mode 100644 src/LinkStrategy/BasicLink.php create mode 100644 src/LinkStrategy/InheritedLink.php create mode 100644 src/LinkStrategy/LinkStrategyEnum.php create mode 100644 src/LinkStrategy/NoneLink.php create mode 100644 templates/group_link.html.twig diff --git a/CHANGELOG.md b/CHANGELOG.md index a59cb6e..a17d1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [unreleased] +## [Unreleased] ### Fixed diff --git a/hook.php b/hook.php index 676ccbb..0948759 100644 --- a/hook.php +++ b/hook.php @@ -35,6 +35,7 @@ use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Escalation; +use GlpiPlugin\Moreoptions\Group_Link; function plugin_moreoptions_install(): bool { @@ -42,6 +43,7 @@ function plugin_moreoptions_install(): bool Config::install($migration); Escalation::install($migration); + Group_Link::install($migration); $migration->executeMigration(); return true; } @@ -52,6 +54,7 @@ function plugin_moreoptions_uninstall(): bool Config::uninstall($migration); Escalation::uninstall($migration); + Group_Link::uninstall($migration); return true; } diff --git a/setup.php b/setup.php index 96afaca..dd06f8c 100644 --- a/setup.php +++ b/setup.php @@ -37,6 +37,7 @@ use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Controller; use GlpiPlugin\Moreoptions\Escalation; +use GlpiPlugin\Moreoptions\Group_Link; /** @phpstan-ignore theCodingMachineSafe.function (safe to assume this isn't already defined) */ define('PLUGIN_MOREOPTIONS_VERSION', '1.0.0-rc2'); @@ -67,6 +68,8 @@ function plugin_init_moreoptions(): void Plugin::registerClass(Config::class, ['addtabon' => 'Entity']); + Plugin::registerClass(Group_Link::class, ['addtabon' => 'Group']); + $PLUGIN_HOOKS[Hooks::ADD_CSS]['moreoptions'][] = 'css/moreoptions.scss'; $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Entity::class] = Config::addConfig(...); diff --git a/src/Group_Link.php b/src/Group_Link.php new file mode 100644 index 0000000..025c65f --- /dev/null +++ b/src/Group_Link.php @@ -0,0 +1,150 @@ +isNewItem()) { + return ''; + } + + return self::createTabEntry(self::getTypeName(), 0, $item::class, self::getIcon()); + } + + public static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $withtemplate = 0): bool + { + if ($item instanceof Group) { + self::showForGroup($item); + } + + return true; + } + + /** + * Renders the "Escalation" tab of the given group. + */ + public static function showForGroup(Group $group): void + { + TemplateRenderer::getInstance()->display('@moreoptions/group_link.html.twig'); + } + + /** + * The links applying in the given entity. For each pair of groups, the link of the closest + * entity wins, from the given entity up to the root entity: a LinkStrategyEnum::BASIC link + * only applies in its own entity, a LinkStrategyEnum::INHERITED one also in its sub-entities, + * and a LinkStrategyEnum::NONE one removes the inherited link from its entity and its + * sub-entities. + * + * @return array> + */ + public static function getLinksForEntity(int $entities_id): array + { + return []; + } + + public function getLinkStrategy(): LinkStrategyEnum + { + return LinkStrategyEnum::tryFrom((string) ($this->fields['link_type'] ?? '')) ?? LinkStrategyEnum::getDefault(); + } + + public static function install(Migration $migration): void + { + /** @var DBmysql $DB */ + global $DB; + + $table = self::getTable(); + if (!$DB->tableExists($table)) { + $migration->displayMessage('Installing ' . $table); + $default_charset = DBConnection::getDefaultCharset(); + $default_collation = DBConnection::getDefaultCollation(); + $default_key_sign = DBConnection::getDefaultPrimaryKeySignOption(); + + $DB->doQuery("CREATE TABLE `{$table}` ( + `id` int {$default_key_sign} NOT NULL AUTO_INCREMENT, + `groups_id_source` int {$default_key_sign} NOT NULL DEFAULT '0', + `groups_id_destination` int {$default_key_sign} NOT NULL DEFAULT '0', + `entities_id` int {$default_key_sign} NOT NULL DEFAULT '0', + `link_type` varchar(30) NOT NULL DEFAULT '', + `date_creation` timestamp NULL DEFAULT NULL, + `date_mod` timestamp NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `unicity` (`entities_id`, `groups_id_source`, `groups_id_destination`), + KEY `groups_id_source` (`groups_id_source`), + KEY `groups_id_destination` (`groups_id_destination`), + KEY `date_creation` (`date_creation`), + KEY `date_mod` (`date_mod`) + ) ENGINE=InnoDB DEFAULT CHARSET={$default_charset} COLLATE={$default_collation} ROW_FORMAT=DYNAMIC; + "); + } + } + + public static function uninstall(Migration $migration): void + { + $migration->dropTable(self::getTable()); + } +} diff --git a/src/LinkStrategy/AbstractLinkStrategy.php b/src/LinkStrategy/AbstractLinkStrategy.php new file mode 100644 index 0000000..487bec7 --- /dev/null +++ b/src/LinkStrategy/AbstractLinkStrategy.php @@ -0,0 +1,74 @@ + + */ + public function getArrowOptions(): array + { + return []; + } + + /** + * Whether the link is replicated in the sub-entities of its entity + */ + public function appliesToSubEntities(): bool + { + return false; + } + + /** + * Whether the link stops, from its entity, the replication of an inherited link + */ + public function blocksInheritance(): bool + { + return false; + } + + /** + * Get the label of the link strategy + */ + abstract public function getLabel(): string; + + /** + * Get the icon of the link strategy + */ + abstract public function getIcon(): string; +} diff --git a/src/LinkStrategy/BasicLink.php b/src/LinkStrategy/BasicLink.php new file mode 100644 index 0000000..a0d2f75 --- /dev/null +++ b/src/LinkStrategy/BasicLink.php @@ -0,0 +1,51 @@ + new NoneLink(), + self::BASIC => new BasicLink(), + self::INHERITED => new InheritedLink(), + }; + } + + /** + * Get the default strategy + */ + public static function getDefault(): self + { + return self::NONE; + } + + /** + * Get all available link strategies + * + * @return array + */ + public static function getAvailableStrategies(): array + { + $strategies = []; + + foreach (LinkStrategyEnum::cases() as $case) { + $strategies[$case->value] = $case->getStrategy(); + } + + return $strategies; + } +} diff --git a/src/LinkStrategy/NoneLink.php b/src/LinkStrategy/NoneLink.php new file mode 100644 index 0000000..50bdf08 --- /dev/null +++ b/src/LinkStrategy/NoneLink.php @@ -0,0 +1,56 @@ + Date: Thu, 1 Oct 2026 12:09:55 +0200 Subject: [PATCH 6/9] Feat escalade options (#21) * Add options * Add units tests and fix ui * Fix lints * Fix * Fix * Fix * Fix ci * Fix ci * Refactor getStatusConfigFields --- CHANGELOG.md | 1 - public/css/moreoptions.scss | 4 +- src/Config.php | 100 ++++++- src/Escalation.php | 87 +++++- templates/config.html.twig | 20 +- templates/escalation_button.html.twig | 2 +- templates/escalation_form.html.twig | 7 +- tests/Units/EscalationTest.php | 394 +++++++++++++++++++++++++- tests/bootstrap.php | 4 +- 9 files changed, 594 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a17d1c1..d88f7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,4 +24,3 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed the issue where a ticket could be solved without a solution ## [1.0.0-rc1] - diff --git a/public/css/moreoptions.scss b/public/css/moreoptions.scss index 5d04a19..cd28a87 100644 --- a/public/css/moreoptions.scss +++ b/public/css/moreoptions.scss @@ -83,7 +83,9 @@ display: none; } - .form-field:has(.mo-seg-inherit:checked) .mo-inherited { + // Same for the status dropdowns, whose "Inherit" option is Config::CONFIG_PARENT (-2). + .form-field:has(.mo-seg-inherit:checked) .mo-inherited, + .form-field:has(.mo-status option[value="-2"]:checked) .mo-inherited { display: inline-flex; align-items: center; } diff --git a/src/Config.php b/src/Config.php index 458227c..660021f 100644 --- a/src/Config.php +++ b/src/Config.php @@ -37,13 +37,17 @@ namespace GlpiPlugin\Moreoptions; use DBmysql; +use Change; use CommonDBTM; use CommonGLPI; +use CommonITILObject; use Entity; use Glpi\Application\View\TemplateRenderer; use Migration; use Plugin; +use Problem; use Session; +use Ticket; class Config extends CommonDBTM { @@ -141,7 +145,36 @@ private static function getActorGroupConfigFields(): array */ private static function getAllConfigFields(): array { - return array_merge(self::getItilConfigFields(), self::getActorGroupConfigFields()); + return array_merge( + self::getItilConfigFields(), + self::getActorGroupConfigFields(), + array_keys(self::getStatusConfigFields()), + ); + } + + /** + * Fields of kind `status`, with the ITIL itemtype whose statuses they hold. + * + * @return array> + */ + private static function getStatusConfigFields(): array + { + return [ + 'escalade_status_after_escalation_ticket' => Ticket::class, + 'escalade_status_after_escalation_change' => Change::class, + 'escalade_status_after_escalation_problem' => Problem::class, + ]; + } + + /** + * Choices of a `status` field: "No change" (0), then every status of the itemtype. + * + * @param class-string $itemtype + * @return array + */ + public static function getSelectableStatus(string $itemtype): array + { + return [0 => __('No change', 'moreoptions')] + $itemtype::getAllStatusArray(); } /** @@ -290,6 +323,14 @@ public static function showForEntity(Entity $item): void 'entities_id' => $item->getID(), ]); + $status_options = []; + foreach (self::getStatusConfigFields() as $field => $itemtype) { + $status_options[$field] = self::getSelectableStatus($itemtype); + if ($item->getID() > 0) { + $status_options[$field] = [self::CONFIG_PARENT => __('Inherit', 'moreoptions')] + $status_options[$field]; + } + } + $tabs = self::getScreenTabs(); $sections_by_tab = []; foreach ($tabs as $tab) { @@ -305,6 +346,7 @@ public static function showForEntity(Entity $item): void 'parent_entity_id' => $item->getID() > 0 ? (int) $item->fields['entities_id'] : null, 'parent_badges' => self::getParentValueBadges($item), 'dropdown_options' => self::getSelectableActorGroup(), + 'status_options' => $status_options, 'config_parent' => self::CONFIG_PARENT, 'escalade_takes_technician_group' => self::isTechnicianGroupHandledByEscalade(), 'params' => [ @@ -327,11 +369,11 @@ public static function getIcon(): string public static function getScreenTabs(): array { return [ - ['id' => 'ticket', 'label' => __('Ticket'), 'icon' => 'ti-ticket'], - ['id' => 'change', 'label' => __('Change'), 'icon' => 'ti-git-branch'], - ['id' => 'problem', 'label' => __('Problem'), 'icon' => 'ti-alert-circle'], + ['id' => 'ticket', 'label' => __('Ticket'), 'icon' => Ticket::getIcon()], + ['id' => 'change', 'label' => __('Change'), 'icon' => Change::getIcon()], + ['id' => 'problem', 'label' => __('Problem'), 'icon' => Problem::getIcon()], ['id' => 'task', 'label' => _n('Task', 'Tasks', 2), 'icon' => 'ti-checklist'], - ['id' => 'escalate', 'label' => __('Escalate', 'moreoptions'), 'icon' => 'ti-arrow-up'], + ['id' => 'escalate', 'label' => __('Escalate', 'moreoptions'), 'icon' => Escalation::getIcon(),], ]; } @@ -401,9 +443,21 @@ private static function getScreenSections(): array 'escalate' => [ [ 'title' => __('Escalate', 'moreoptions'), - 'icon' => 'ti-arrow-up', + 'icon' => Escalation::getIcon(), 'rows' => [ ['key' => 'escalate_is_active', 'kind' => 'yes_no', 'label' => __('Activate escalation', 'moreoptions')], + ['key' => 'escalate_remove_technician', 'kind' => 'yes_no', 'label' => __('Remove technician after escalation', 'moreoptions')], + ['key' => 'escalade_status_after_escalation_ticket', 'kind' => 'status', 'label' => __('Ticket status after escalation', 'moreoptions')], + ['key' => 'escalade_status_after_escalation_change', 'kind' => 'status', 'label' => __('Change status after escalation', 'moreoptions')], + ['key' => 'escalade_status_after_escalation_problem', 'kind' => 'status', 'label' => __('Problem status after escalation', 'moreoptions')], + ], + ], + [ + 'title' => __('Default options values', 'moreoptions'), + 'icon' => 'ti-settings', + 'rows' => [ + ['key' => 'escalade_assign_me_as_obsever_by_default', 'kind' => 'yes_no', 'label' => __('Assign me as observer after escalation', 'moreoptions')], + ['key' => 'escalade_is_private_by_default', 'kind' => 'yes_no', 'label' => __('Escalate event is private', 'moreoptions')], ], ], ], @@ -477,6 +531,11 @@ private static function getParentValueBadges(Entity $item): array $badges[$field] = Entity::inheritedValue(htmlescape($text), false, false); } + foreach (self::getStatusConfigFields() as $field => $itemtype) { + $text = self::getSelectableStatus($itemtype)[(int) ($parent_config->fields[$field] ?? 0)] ?? __('No change', 'moreoptions'); + $badges[$field] = Entity::inheritedValue(htmlescape($text), false, false); + } + return $badges; } @@ -577,6 +636,12 @@ public static function install(Migration $migration): void `assign_technician_from_task_change` tinyint NOT NULL DEFAULT '0', `assign_technician_from_task_problem` tinyint NOT NULL DEFAULT '0', `escalate_is_active` tinyint NOT NULL DEFAULT '0', + `escalate_remove_technician` tinyint NOT NULL DEFAULT '0', + `escalade_status_after_escalation_ticket` tinyint NOT NULL DEFAULT '0', + `escalade_status_after_escalation_change` tinyint NOT NULL DEFAULT '0', + `escalade_status_after_escalation_problem` tinyint NOT NULL DEFAULT '0', + `escalade_assign_me_as_obsever_by_default` tinyint NOT NULL DEFAULT '0', + `escalade_is_private_by_default` tinyint NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `entities_id` (`entities_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; @@ -596,6 +661,12 @@ public static function install(Migration $migration): void 'assign_technician_from_task_change', 'assign_technician_from_task_problem', 'escalate_is_active', + 'escalate_remove_technician', + 'escalade_status_after_escalation_ticket', + 'escalade_status_after_escalation_change', + 'escalade_status_after_escalation_problem', + 'escalade_assign_me_as_obsever_by_default', + 'escalade_is_private_by_default', ] as $field ) { if (!$DB->fieldExists($table, $field)) { @@ -603,8 +674,25 @@ public static function install(Migration $migration): void } } + // Status fields added to an existing table: child entities inherit by default. + $new_status_fields = []; + foreach (array_keys(self::getStatusConfigFields()) as $field) { + if (!$DB->fieldExists($table, $field)) { + $migration->addField($table, $field, 'bool', ['value' => '0']); + $new_status_fields[] = $field; + } + } + $migration->executeMigration(); + if ($new_status_fields !== []) { + $DB->update( + $table, + array_fill_keys($new_status_fields, self::CONFIG_PARENT), + ['entities_id' => ['>', 0]], + ); + } + $entities = new Entity(); foreach ($entities->find() as $entity) { if (is_array($entity) && isset($entity['id'])) { diff --git a/src/Escalation.php b/src/Escalation.php index 534bc6b..7c9e546 100644 --- a/src/Escalation.php +++ b/src/Escalation.php @@ -72,7 +72,7 @@ public static function getTypeName($nb = 0): string public static function getIcon(): string { - return 'ti ti-arrow-up'; + return 'ti ti-escalator-up'; } /** @@ -275,6 +275,32 @@ public function post_addItem() 'type' => CommonITILActor::ASSIGN, '_plugin_moreoptions_escalade' => true, ]); + + if ((int) ($this->input['add_me_as_observer'] ?? 0) === 1) { + $this->addAuthorAsObserver($item); + } + } + + /** + * Adds the author of the escalation as an observer of the escalated item, unless they already are. + */ + private function addAuthorAsObserver(CommonITILObject $item): void + { + $user_link = getItemForItemtype($item->userlinkclass); + if (!$user_link instanceof CommonITILActor) { + return; + } + + $input = [ + $item->getForeignKeyField() => (int) $this->fields['items_id'], + 'users_id' => (int) $this->fields['users_id'], + 'type' => CommonITILActor::OBSERVER, + ]; + if (countElementsInTable($user_link::getTable(), $input) > 0) { + return; + } + + $user_link->add($input); } /** @@ -342,9 +368,16 @@ public static function showEscalationForm(CommonITILObject $item): void $groups_used[$key] = (int) $row['groups_id']; } + $config = Config::getConfig((int) $item->fields['entities_id']); + TemplateRenderer::getInstance()->display('@moreoptions/escalation_form.html.twig', [ 'item' => $item, 'groups_used' => $groups_used ?? [], + // Default values of the form options + 'config' => [ + 'assign_to_observer' => (int) ($config->fields['escalade_assign_me_as_obsever_by_default'] ?? 0) === 1, + 'is_private' => (int) ($config->fields['escalade_is_private_by_default'] ?? 0) === 1, + ], ]); } @@ -364,6 +397,14 @@ public static function escalate(CommonITILActor $group_link): void } self::keepOnlyAssignedGroup($group_link); + + $item = getItemForItemtype($group_link::$itemtype_1 ?? ''); + if (!$item instanceof CommonITILObject || !$item->getFromDB((int) $group_link->fields[$group_link::$items_id_1])) { + return; + } + + self::removeTechnician($item); + self::changeStatusAfterEscalation($item); } /** @@ -383,6 +424,50 @@ private static function keepOnlyAssignedGroup(CommonITILActor $group_link): void } } + /** + * Drop the technicians assigned to the escalated item, when the "remove technician" option + * is enabled for its entity. + */ + private static function removeTechnician(CommonITILObject $item): void + { + $config = Config::getConfig((int) $item->fields['entities_id']); + if ((int) ($config->fields['escalate_remove_technician'] ?? 0) !== 1) { + return; + } + + $user_link = getItemForItemtype($item->userlinkclass); + if (!$user_link instanceof CommonITILActor) { + return; + } + + $technician_links = $user_link->find([ + $item->getForeignKeyField() => $item->getID(), + 'type' => CommonITILActor::ASSIGN, + ]); + foreach ($technician_links as $technician_link) { + (new ($user_link::class)())->delete(['id' => $technician_link['id']]); + } + } + + /** + * Set the status configured for its entity on the escalated item, unless it already has it. + */ + private static function changeStatusAfterEscalation(CommonITILObject $item): void + { + // Reload: removing the actors (see self::removeTechnician()) may have changed the status. + if (!$item->getFromDB($item->getID())) { + return; + } + + $config = Config::getConfig((int) $item->fields['entities_id']); + $new_status = (int) ($config->fields['escalade_status_after_escalation_' . strtolower($item::class)] ?? 0); + if ($new_status === 0 || (int) $item->fields['status'] === $new_status) { + return; + } + + $item->update(['id' => $item->getID(), 'status' => $new_status]); + } + public static function install(Migration $migration): void { /** @var DBmysql $DB */ diff --git a/templates/config.html.twig b/templates/config.html.twig index 6a52a49..2958e73 100644 --- a/templates/config.html.twig +++ b/templates/config.html.twig @@ -81,7 +81,7 @@ aria-selected="{{ loop.first ? 'true' : 'false' }}" > - {{ tab.label }} +  {{ tab.label }} {% endfor %} @@ -121,7 +121,23 @@ {% endif %}
- {{ mo.render(row.field, options, current, parent_badges[row.field]|default(null), disabled, canedit, rand) }} + {% if row.kind == 'status' %} + {% if parent_badges[row.field] is defined %} +
{{ parent_badges[row.field]|raw }}
+ {% endif %} +
+ {{ call('Dropdown::showFromArray', [row.field, status_options[row.field], { + 'value': current, + 'width': '220px', + 'disabled': disabled or not canedit, + 'templateResult': 'templateItilStatus', + 'templateSelection': 'templateItilStatus', + 'display': false, + }])|raw }} +
+ {% else %} + {{ mo.render(row.field, options, current, parent_badges[row.field]|default(null), disabled, canedit, rand) }} + {% endif %}
diff --git a/templates/escalation_button.html.twig b/templates/escalation_button.html.twig index 4f625b2..ac3e8ee 100644 --- a/templates/escalation_button.html.twig +++ b/templates/escalation_button.html.twig @@ -53,7 +53,7 @@ button.type = 'button'; button.className = 'btn btn-sm btn-ghost-secondary moreoptions-escalate-btn ms-2 py-0 px-1'; button.title = {{ __('Escalate', 'moreoptions')|json_encode|raw }}; - button.innerHTML = '' + button.innerHTML = '' + '' + _.escape({{ __('Escalate', 'moreoptions')|json_encode|raw }}) + ''; button.addEventListener('click', function() { glpi_ajax_dialog({ diff --git a/templates/escalation_form.html.twig b/templates/escalation_form.html.twig index 8af422a..16ecff8 100644 --- a/templates/escalation_form.html.twig +++ b/templates/escalation_form.html.twig @@ -81,6 +81,7 @@ 'condition': {'is_assign': 1}, 'rand': rand, 'aria_label': 'Group'|itemtype_name, + 'used' : groups_used, } ) }} @@ -90,7 +91,7 @@ {% endset %} {{ fields.sliderField( 'add_me_as_observer', - 0, + config.assign_to_observer ? 1 : 0, observer_lbl, { 'full_width': true, @@ -105,7 +106,7 @@ {{ fields.sliderField( 'is_private', - 1, + config.is_private ? 1 : 0, private_label, { 'full_width': true, @@ -119,7 +120,7 @@
diff --git a/tests/Units/EscalationTest.php b/tests/Units/EscalationTest.php index 53ae250..f8ffb5b 100644 --- a/tests/Units/EscalationTest.php +++ b/tests/Units/EscalationTest.php @@ -37,15 +37,21 @@ use CommonITILActor; use CommonITILObject; use Group; +use ITILFollowup; +use Log; use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Escalation; use GlpiPlugin\Moreoptions\Tests\MoreOptionsTestCase; use PHPUnit\Framework\Attributes\DataProvider; use Problem; use Session; +use Symfony\Component\DomCrawler\Crawler; use Ticket; +use User; use function Safe\json_decode; +use function Safe\ob_get_clean; +use function Safe\ob_start; class EscalationTest extends MoreOptionsTestCase { @@ -206,22 +212,367 @@ public function testEscalationToAlreadyAssignedGroupIsRefused(string $itemtype): ))); } + /** + * @return iterable, bool}> + */ + public static function removeTechnicianProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype . ' with "Remove technician after escalation" enabled' => [$itemtype, true]; + yield $itemtype . ' with "Remove technician after escalation" disabled' => [$itemtype, false]; + } + } + + /** + * @param class-string $itemtype + */ + #[DataProvider('removeTechnicianProvider')] + public function testRemoveTechnicianAfterEscalation(string $itemtype, bool $remove_technician): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id, ['escalate_remove_technician' => (int) $remove_technician]); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + // Two technicians assigned to the item, plus a requester and an observer + $technicians = [$this->getUser('tech'), $this->getUser('normal')]; + foreach ($technicians as $technician) { + $this->addUserActor($item, $technician, CommonITILActor::ASSIGN); + } + + $requester = $this->getUser('post-only'); + $observer = $this->getUser('glpi'); + $this->addUserActor($item, $requester, CommonITILActor::REQUESTER); + $this->addUserActor($item, $observer, CommonITILActor::OBSERVER); + + $this->assertSame($this->getIdsOf($technicians), $this->getUserActorIds($item, CommonITILActor::ASSIGN)); + + $target_group = $this->createGroup($entities_id, 'Target group'); + $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $target_group->getID(), + ]); + + // The technicians are removed only when the option is enabled + $this->assertSame( + $remove_technician ? [] : $this->getIdsOf($technicians), + $this->getUserActorIds($item, CommonITILActor::ASSIGN), + ); + + // The other actors are always kept, and the target group is assigned in both cases + $this->assertContains($requester->getID(), $this->getUserActorIds($item, CommonITILActor::REQUESTER)); + $this->assertContains($observer->getID(), $this->getUserActorIds($item, CommonITILActor::OBSERVER)); + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + } + + /** + * @return iterable, array, int>, bool, int|null}> + */ + public static function statusAfterEscalationProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype . ' gets the configured status' => [ + $itemtype, + [$itemtype => CommonITILObject::WAITING], + false, + CommonITILObject::WAITING, + ]; + yield $itemtype . ' already having the configured status is not updated' => [ + $itemtype, + [$itemtype => CommonITILObject::WAITING], + true, + CommonITILObject::WAITING, + ]; + yield $itemtype . ' is not affected by the status configured for the other types' => [ + $itemtype, + array_fill_keys(array_diff([Ticket::class, Change::class, Problem::class], [$itemtype]), CommonITILObject::WAITING), + false, + null, + ]; + } + } + + /** + * @param class-string $itemtype + * @param array, int> $statuses Status to set after escalation, by itemtype + * @param int|null $expected_status Expected status after escalation, null if it must not be changed by the escalation + */ + #[DataProvider('statusAfterEscalationProvider')] + public function testStatusAfterEscalation(string $itemtype, array $statuses, bool $already_has_status, ?int $expected_status): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $options = []; + foreach ([Ticket::class, Change::class, Problem::class] as $configured_itemtype) { + $options['escalade_status_after_escalation_' . strtolower($configured_itemtype)] = $statuses[$configured_itemtype] ?? 0; + } + + $this->enableEscalation($entities_id, $options); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + if ($already_has_status) { + $this->updateItem($itemtype, $item->getID(), ['status' => $expected_status]); + } + + // Status of the item after the escalation when the plugin does not change it (the core + // may move a new item to "Processing (assigned)" when the target group is assigned). + $reference = $this->createItem($itemtype, [ + 'name' => 'Test escalation reference', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $reference); + $this->createItem($reference->grouplinkclass, [ + $reference->getForeignKeyField() => $reference->getID(), + 'groups_id' => $this->createGroup($entities_id, 'Reference group')->getID(), + 'type' => CommonITILActor::ASSIGN, + ]); + $this->assertTrue($reference->getFromDB($reference->getID())); + + $nb_status_logs = $this->countStatusLogs($item); + + $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $this->createGroup($entities_id, 'Target group')->getID(), + ]); + + $this->assertTrue($item->getFromDB($item->getID())); + $this->assertSame( + $expected_status ?? (int) $reference->fields['status'], + (int) $item->fields['status'], + ); + + if ($already_has_status) { + // No useless update: the status history is left untouched + $this->assertSame($nb_status_logs, $this->countStatusLogs($item)); + } + } + + /** + * @return iterable, bool, bool}> + */ + public static function formDefaultsProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype . ' with both options disabled' => [$itemtype, false, false]; + yield $itemtype . ' with "Assign me as observer by default" enabled' => [$itemtype, true, false]; + yield $itemtype . ' with "Private by default" enabled' => [$itemtype, false, true]; + yield $itemtype . ' with both options enabled' => [$itemtype, true, true]; + } + } + + /** + * The "Assign me as an observer" and "Private" switches of the escalation form are checked + * by default according to the entity configuration. + * + * @param class-string $itemtype + */ + #[DataProvider('formDefaultsProvider')] + public function testEscalationFormDefaults(string $itemtype, bool $observer_by_default, bool $private_by_default): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id, [ + 'escalade_assign_me_as_obsever_by_default' => (int) $observer_by_default, + 'escalade_is_private_by_default' => (int) $private_by_default, + ]); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + ob_start(); + try { + Escalation::showEscalationForm($item); + } finally { + $html = ob_get_clean(); + } + + $crawler = new Crawler($html); + + $observer_switch = $crawler->filter('input[type="checkbox"][name="add_me_as_observer"]'); + $this->assertCount(1, $observer_switch); + $this->assertSame($observer_by_default, $observer_switch->attr('checked') !== null); + + $private_switch = $crawler->filter('input[type="checkbox"][name="is_private"]'); + $this->assertCount(1, $private_switch); + $this->assertSame($private_by_default, $private_switch->attr('checked') !== null); + } + + /** + * @return iterable, bool, bool}> + */ + public static function addMeAsObserverProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype . ' with "Assign me as an observer" checked' => [$itemtype, true, false]; + yield $itemtype . ' with "Assign me as an observer" unchecked' => [$itemtype, false, false]; + yield $itemtype . ' with "Assign me as an observer" checked, already observer' => [$itemtype, true, true]; + } + } + + /** + * @param class-string $itemtype + */ + #[DataProvider('addMeAsObserverProvider')] + public function testAddMeAsObserver(string $itemtype, bool $add_me_as_observer, bool $already_observer): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + $me = $this->getUser(self::TU_USER); + if ($already_observer) { + $this->addUserActor($item, $me, CommonITILActor::OBSERVER); + } + + $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $this->createGroup($entities_id, 'Target group')->getID(), + 'add_me_as_observer' => (int) $add_me_as_observer, + ], ['add_me_as_observer']); + + // The author is an observer (only once) when asked, or when they already were + $this->assertSame( + $add_me_as_observer || $already_observer ? [$me->getID()] : [], + $this->getUserActorIds($item, CommonITILActor::OBSERVER), + ); + } + + /** + * @return iterable, bool}> + */ + public static function privateEscalationProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype . ' with a private escalation' => [$itemtype, true]; + yield $itemtype . ' with a public escalation' => [$itemtype, false]; + } + } + + /** + * A private escalation is shown in the timeline only to the users allowed to see private + * followups. + * + * @param class-string $itemtype + */ + #[DataProvider('privateEscalationProvider')] + public function testPrivateEscalation(string $itemtype, bool $is_private): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + $escalation = $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $this->createGroup($entities_id, 'Target group')->getID(), + 'is_private' => (int) $is_private, + ]); + $this->assertSame((int) $is_private, (int) $escalation->fields['is_private']); + + // Allowed to see private followups: the escalation is always shown, flagged as private + $this->assertNotEmpty(Session::haveRight('followup', ITILFollowup::SEEPRIVATE)); + $entries = $this->getEscalationTimelineEntries($item); + $this->assertCount(1, $entries); + $this->assertSame((int) $is_private, (int) $entries[0]['item']['is_private']); + + // Not allowed to see private followups (but still allowed to see the item, or the core + // returns an empty timeline): a private escalation is hidden + $_SESSION['glpiactiveprofile']['followup'] &= ~ITILFollowup::SEEPRIVATE; + $this->assertEmpty(Session::haveRight('followup', ITILFollowup::SEEPRIVATE)); + $this->assertCount($is_private ? 0 : 1, $this->getEscalationTimelineEntries($item)); + } + /** * Enable the escalation option for the given entity. + * + * @param array $options Other escalation options to set */ - private function enableEscalation(int $entities_id): void + private function enableEscalation(int $entities_id, array $options = []): void { + $fields = ['escalate_is_active' => 1] + $options; + $config = Config::getConfig($entities_id, false); if ($config->isNewItem()) { - $this->createTestConfig([ - 'entities_id' => $entities_id, - 'escalate_is_active' => 1, - ]); + $this->createTestConfig(['entities_id' => $entities_id] + $fields); } else { - $this->updateTestConfig($config, ['escalate_is_active' => 1]); + $this->updateTestConfig($config, $fields); } } + private function getUser(string $name): User + { + $user = new User(); + $this->assertTrue($user->getFromDBByCrit(['name' => $name])); + + return $user; + } + + private function addUserActor(CommonITILObject $item, User $user, int $type): void + { + $this->createItem($item->userlinkclass, [ + $item->getForeignKeyField() => $item->getID(), + 'users_id' => $user->getID(), + 'type' => $type, + ]); + } + + /** + * @return array + */ + private function getUserActorIds(CommonITILObject $item, int $type): array + { + $user_link = getItemForItemtype($item->userlinkclass); + $this->assertInstanceOf(CommonITILActor::class, $user_link); + + return array_map( + static fn(array $row): int => (int) $row['users_id'], + array_values($user_link->find([ + $item->getForeignKeyField() => $item->getID(), + 'type' => $type, + ], ['id ASC'])), + ); + } + private function createGroup(int $entities_id, string $name): Group { $group = $this->createItem(Group::class, [ @@ -253,11 +604,36 @@ private function getAssignedGroupIds(CommonITILObject $item): array } /** - * @param array $groups + * @return array}> + */ + private function getEscalationTimelineEntries(CommonITILObject $item): array + { + $this->assertTrue($item->getFromDB($item->getID())); + + return array_values(array_filter( + $item->getTimelineItems(), + static fn(array $entry): bool => $entry['type'] === Escalation::class, + )); + } + + /** + * Number of changes of the item status in its history. + */ + private function countStatusLogs(CommonITILObject $item): int + { + return countElementsInTable(Log::getTable(), [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'id_search_option' => 12, + ]); + } + + /** + * @param array $items * @return array */ - private function getIdsOf(array $groups): array + private function getIdsOf(array $items): array { - return array_map(static fn(Group $group): int => $group->getID(), $groups); + return array_map(static fn(Group|User $item): int => $item->getID(), $items); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3856728..308e3a6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -32,7 +32,9 @@ */ require_once __DIR__ . '/../../../tests/bootstrap.php'; -require_once __DIR__ . '/../vendor/autoload.php'; +// Not the plugin `vendor/autoload.php`: it would load the dev tools dependencies (e.g. an older +// `twig/twig`) over the GLPI core ones. +require_once __DIR__ . '/MoreOptionsTestCase.php'; if (!Plugin::isPluginActive('moreoptions')) { throw new RuntimeException('Plugin moreoptions is not active in the test database'); From dcb4d9a5d363b16b616250ad99f276a260e76679 Mon Sep 17 00:00:00 2001 From: Samuel Launay <107540223+Lainow@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:09:55 +0200 Subject: [PATCH 7/9] Implement escalation history (#23) * Implement escalation history * Refactor * Update changelog --- CHANGELOG.md | 1 + public/css/moreoptions.scss | 36 +++++- src/Escalation.php | 160 ++++++++++++++++++++------ templates/escalation_button.html.twig | 128 +++++++++++++++++++-- tests/Units/EscalationTest.php | 63 ++++++++++ 5 files changed, 339 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d88f7d8..a17d1c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,3 +24,4 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed the issue where a ticket could be solved without a solution ## [1.0.0-rc1] + diff --git a/public/css/moreoptions.scss b/public/css/moreoptions.scss index cd28a87..107bc64 100644 --- a/public/css/moreoptions.scss +++ b/public/css/moreoptions.scss @@ -135,7 +135,7 @@ } // Escalation entries in the timeline (see Escalation::getTimelineContent() and -// escalation_timeline.html.twig): a centered card with a dashed border, holding the +// escalation_timeline.html.twig): a centered card, with a dashed border when private, holding the // " | -> " summary next to the "Created: ... by ..." badge, // then the escalation comment, which can be collapsed. .timeline-item.moreoptions-escalation { @@ -159,8 +159,8 @@ background: var(--mo-escalation-background); // Core squares the corner the (hidden) speech bubble arrow points to. border-radius: var(--tblr-border-radius-lg) !important; - // Also replaces the thicker dashed border of "internal" entries (`.private-item`). - border: 1px dashed var(--mo-escalation-border); + // Dashed only for "internal" entries (`.private-item`), replacing their thicker core border. + border: 1px solid var(--mo-escalation-border); box-shadow: none; // Speech bubble arrow pointing to the (hidden) avatar @@ -175,6 +175,10 @@ } } + &.private-item .content-part .timeline-content { + border-style: dashed; + } + .timeline-header { flex-wrap: wrap; align-items: center; @@ -238,3 +242,29 @@ margin-top: 0.75rem; } } + +// "Escalation history" popover, opened by the button next to the "Escalate" one (see +// escalation_button.html.twig). +.popover.moreoptions-escalation-history-popover { + --tblr-popover-max-width: 28rem; + + .popover-body { + max-height: 20rem; + overflow-y: auto; + } + + .moreoptions-escalation-groups { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.5rem; + font-style: italic; + color: var(--tblr-body-color); + } + + .moreoptions-escalation-group { + display: inline-flex; + align-items: center; + gap: 0.25rem; + } +} diff --git a/src/Escalation.php b/src/Escalation.php index 7c9e546..64a9a17 100644 --- a/src/Escalation.php +++ b/src/Escalation.php @@ -113,20 +113,8 @@ public static function showInTimeline(array $params): void /** @var array $timeline */ $timeline = &$params['timeline']; - $can_see_private = Session::haveRight('followup', ITILFollowup::SEEPRIVATE); - $criterias = [ - 'itemtype' => $item::class, - 'items_id' => $item->getID(), - ]; - - if (!$can_see_private) { - $criterias['is_private'] = 0; - } - - $escalations = (new self())->find($criterias); - - foreach ($escalations as $row) { + foreach (self::getEscalationsOf($item) as $row) { $timeline['MoreoptionsEscalation_' . $row['id']] = [ 'type' => self::getType(), 'class' => 'moreoptions-escalation', @@ -145,6 +133,79 @@ public static function showInTimeline(array $params): void } } + /** + * The escalations of the item the current user can see (private ones require the right to see + * private followups). + * + * @return array> + */ + private static function getEscalationsOf(CommonITILObject $item, string $order = 'id ASC'): array + { + $criterias = [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + ]; + + if (!Session::haveRight('followup', ITILFollowup::SEEPRIVATE)) { + $criterias['is_private'] = 0; + } + + return (new self())->find($criterias, $order); + } + + /** + * Why the item cannot be escalated to the given group, or null when it can: the group must + * exist, be assignable, be visible from the item entity and not be already assigned to the item. + */ + public static function getEscalationBlocker(CommonITILObject $item, int $groups_id): ?string + { + $group = new Group(); + if ($groups_id <= 0 || !$group->getFromDB($groups_id)) { + return __('This group no longer exists.', 'moreoptions'); + } + + if ((int) $group->fields['is_assign'] !== 1) { + return __('This group can no longer be assigned.', 'moreoptions'); + } + + $item_entity = (int) $item->fields['entities_id']; + $group_entity = (int) $group->fields['entities_id']; + if ( + $group_entity !== $item_entity + && ( + (int) $group->fields['is_recursive'] !== 1 + || !in_array($group_entity, array_map(intval(...), getAncestorsOf('glpi_entities', $item_entity)), true) + ) + ) { + return __('This group is not visible from the entity of the item.', 'moreoptions'); + } + + if (in_array($groups_id, self::getAssignedGroupIds($item), true)) { + return __('This group is already assigned.', 'moreoptions'); + } + + return null; + } + + /** + * @return array + */ + private static function getAssignedGroupIds(CommonITILObject $item): array + { + $group_link = getItemForItemtype($item->grouplinkclass); + if (!$group_link instanceof CommonDBTM) { + return []; + } + + return array_values(array_map( + static fn(array $row): int => (int) $row['groups_id'], + $group_link->find([ + $item->getForeignKeyField() => $item->getID(), + 'type' => CommonITILActor::ASSIGN, + ]), + )); + } + /** * The timeline entry of an escalation: a header line " | -> " * (see escalation_timeline.html.twig, which puts it next to the "Created: ... by ..." badge), @@ -211,7 +272,8 @@ private static function getSourceGroupIds(array $row): array /** * The author is always the current user, and the source groups are the groups assigned to - * the item before the escalation. Escalating to a group already assigned is refused. + * the item before the escalation. Escalating to a group the item cannot be escalated to (see + * self::getEscalationBlocker()) is refused. * * @param array $input * @return array|false @@ -225,28 +287,14 @@ public function prepareInputForAdd($input) $input['users_id'] = Session::getLoginUserID(); - $groups_ids_source = []; - $group_link = getItemForItemtype($item->grouplinkclass); - if ($group_link instanceof CommonDBTM) { - foreach ( - $group_link->find([ - $item->getForeignKeyField() => $item->getID(), - 'type' => CommonITILActor::ASSIGN, - ]) as $assigned - ) { - $groups_ids_source[] = (int) $assigned['groups_id']; - } - } - - if (in_array((int) ($input['groups_id'] ?? 0), $groups_ids_source, true)) { - Session::addMessageAfterRedirect( - __s('This group is already assigned.', 'moreoptions'), - false, - ERROR, - ); + $blocker = self::getEscalationBlocker($item, (int) ($input['groups_id'] ?? 0)); + if ($blocker !== null) { + Session::addMessageAfterRedirect(htmlescape($blocker), false, ERROR); return false; } + $groups_ids_source = self::getAssignedGroupIds($item); + $input['groups_ids_source'] = json_encode($groups_ids_source); return $input; @@ -313,17 +361,55 @@ private function addAuthorAsObserver(CommonITILObject $item): void public static function showEscalateButton(array $params): void { $item = $params['item'] ?? null; - if (!$item instanceof CommonITILObject || $item->isNewItem() || !$item->canAssign() || !self::isEnabledFor($item)) { + if (!$item instanceof CommonITILObject || $item->isNewItem() || !self::isEnabledFor($item)) { + return; + } + + $can_escalate = $item->canAssign(); + $history = self::getHistory($item, $can_escalate); + if (!$can_escalate && $history === []) { return; } TemplateRenderer::getInstance()->display('@moreoptions/escalation_button.html.twig', [ - 'marker_id' => 'moreoptions-escalate-' . $item->getType() . '-' . $item->getID(), - 'itemtype' => $item->getType(), - 'items_id' => $item->getID(), + 'marker_id' => 'moreoptions-escalate-' . $item->getType() . '-' . $item->getID(), + 'itemtype' => $item->getType(), + 'items_id' => $item->getID(), + 'can_escalate' => $can_escalate, + 'history' => $history, ]); } + /** + * The escalations of the item, most recent first, as shown in the "Escalation history" popover + * (see escalation_button.html.twig). Each entry tells whether the item can be escalated again + * to its target group, and why not otherwise. + * + * @return array, target: string, groups_id: int, blocker: ?string}> + */ + private static function getHistory(CommonITILObject $item, bool $can_escalate): array + { + $group_name = static fn(int $groups_id): string => Dropdown::getDropdownName(Group::getTable(), $groups_id); + + $history = []; + foreach (self::getEscalationsOf($item, 'date_creation DESC, id DESC') as $row) { + $groups_id = (int) $row['groups_id']; + $history[] = [ + 'id' => (int) $row['id'], + 'date' => (string) $row['date_creation'], + 'author' => getUserName((int) $row['users_id']), + 'sources' => array_map($group_name, self::getSourceGroupIds($row)), + 'target' => $group_name($groups_id), + 'groups_id' => $groups_id, + 'blocker' => $can_escalate + ? self::getEscalationBlocker($item, $groups_id) + : __('You are not allowed to assign this item.', 'moreoptions'), + ]; + } + + return $history; + } + /** * Called from the {@link \Glpi\Plugin\Hooks::TIMELINE_ACTIONS} hook (see * Controller::showTimelineActions()). Renders the script that lays out the header of escalation diff --git a/templates/escalation_button.html.twig b/templates/escalation_button.html.twig index ac3e8ee..cb3f0f1 100644 --- a/templates/escalation_button.html.twig +++ b/templates/escalation_button.html.twig @@ -32,10 +32,48 @@ {# # Rendered through the core `Hooks::TIMELINE_ACTIONS` hook (see Escalation::showEscalateButton()). # The "Assigned to" field is rendered by GLPI core, so this only drops an invisible anchor + - # script that adds a small "Escalate" button next to its label. The button opens the escalation - # form in a modal (see ajax/escalation_form.php). + # script that adds, next to its label: + # - a small "Escalate" button, opening the escalation form in a modal (see ajax/escalation_form.php), + # - a small "Escalation history" button, opening a popover listing the escalations of the item. + # Each one has a button escalating again to its target group, through the same modal with the + # group preselected once it is shown, disabled when it is not possible (see + # Escalation::getEscalationBlocker(), also checked when the escalation is added). #} +{% if history is not empty %} + +{% endif %} diff --git a/tests/Units/EscalationTest.php b/tests/Units/EscalationTest.php index f8ffb5b..9b23f14 100644 --- a/tests/Units/EscalationTest.php +++ b/tests/Units/EscalationTest.php @@ -36,6 +36,7 @@ use Change; use CommonITILActor; use CommonITILObject; +use Entity; use Group; use ITILFollowup; use Log; @@ -522,6 +523,68 @@ public function testPrivateEscalation(string $itemtype, bool $is_private): void $this->assertCount($is_private ? 0 : 1, $this->getEscalationTimelineEntries($item)); } + /** + * @return iterable}> + */ + public static function blockerItemtypeProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype => [$itemtype]; + } + } + + /** + * An item can be escalated again to a group only when it still exists, is assignable, is + * visible from the item entity and is not already assigned. + * + * @param class-string $itemtype + */ + #[DataProvider('blockerItemtypeProvider')] + public function testEscalationBlocker(string $itemtype): void + { + $this->login(); + $root_entity = $this->getTestRootEntity(true); + $child_entity = getItemByTypeName(Entity::class, '_test_child_1', true); + $this->assertIsInt($root_entity); + $this->assertIsInt($child_entity); + $this->enableEscalation($root_entity); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $root_entity, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + $group = $this->createGroup($root_entity, 'Target group'); + $this->assertNull(Escalation::getEscalationBlocker($item, $group->getID())); + + $this->assertSame('This group no longer exists.', Escalation::getEscalationBlocker($item, 0)); + + $this->updateItem(Group::class, $group->getID(), ['is_assign' => 0]); + $this->assertTrue($group->getFromDB($group->getID())); + $this->assertSame('This group can no longer be assigned.', Escalation::getEscalationBlocker($item, $group->getID())); + + $child_group = $this->createGroup($child_entity, 'Child group'); + $this->assertSame('This group is not visible from the entity of the item.', Escalation::getEscalationBlocker($item, $child_group->getID())); + + $assigned_group = $this->createGroup($root_entity, 'Assigned group'); + $this->assertNotFalse((new Escalation())->add([ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $assigned_group->getID(), + ])); + $this->assertSame('This group is already assigned.', Escalation::getEscalationBlocker($item, $assigned_group->getID())); + + // The escalation itself is refused as well + $this->assertFalse((new Escalation())->add([ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $child_group->getID(), + ])); + $this->hasSessionMessages(ERROR, ['This group is not visible from the entity of the item.']); + } + /** * Enable the escalation option for the given entity. * From e7f2471f94d9eaff73c5d897d3787df77bab25d9 Mon Sep 17 00:00:00 2001 From: Samuel Launay <107540223+Lainow@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:01:16 +0200 Subject: [PATCH 8/9] Feat add escalate rule action (#26) * Add escalate rules action * Fix changelog * Fix CI --- CHANGELOG.md | 2 +- front/escalation_rules.php | 65 +++ hook.php | 14 + psalm.xml | 1 + public/css/moreoptions.scss | 22 + setup.php | 20 + src/EscalationRule.php | 451 ++++++++++++++++ templates/config.html.twig | 4 + templates/escalation_rules.html.twig | 104 ++++ templates/escalation_rules_banner.html.twig | 48 ++ tests/Units/EscalationRuleTest.php | 565 ++++++++++++++++++++ 11 files changed, 1295 insertions(+), 1 deletion(-) create mode 100644 front/escalation_rules.php create mode 100644 src/EscalationRule.php create mode 100644 templates/escalation_rules.html.twig create mode 100644 templates/escalation_rules_banner.html.twig create mode 100644 tests/Units/EscalationRuleTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a17d1c1..de82dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Add rector config - Implementation of the basic concept of escalation +- Add the "Escalate to group" action to the ticket, change and problem business rules, and a page to switch the rules "Technician group" actions to it ## [1.0.0-rc2] @@ -24,4 +25,3 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed the issue where a ticket could be solved without a solution ## [1.0.0-rc1] - diff --git a/front/escalation_rules.php b/front/escalation_rules.php new file mode 100644 index 0000000..c4be943 --- /dev/null +++ b/front/escalation_rules.php @@ -0,0 +1,65 @@ + 0) { + Session::addMessageAfterRedirect(htmlescape(sprintf( + _n('%d rule action has been updated.', '%d rule actions have been updated.', $result['updated'], 'moreoptions'), + $result['updated'], + ))); + } + + if ($result['failed'] > 0) { + Session::addMessageAfterRedirect(htmlescape(sprintf( + _n('%d rule action cannot be updated.', '%d rule actions cannot be updated.', $result['failed'], 'moreoptions'), + $result['failed'], + )), false, ERROR); + } + + Html::back(); +} + +Html::header(__('Escalation in business rules', 'moreoptions'), '', 'admin', 'rule'); +EscalationRule::showRulesList(); +Html::footer(); diff --git a/hook.php b/hook.php index 0948759..548667f 100644 --- a/hook.php +++ b/hook.php @@ -35,6 +35,7 @@ use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Escalation; +use GlpiPlugin\Moreoptions\EscalationRule; use GlpiPlugin\Moreoptions\Group_Link; function plugin_moreoptions_install(): bool @@ -54,7 +55,20 @@ function plugin_moreoptions_uninstall(): bool Config::uninstall($migration); Escalation::uninstall($migration); + EscalationRule::uninstall($migration); Group_Link::uninstall($migration); return true; } + +/** + * Adds the "Escalate to group" action to the ticket / change / problem rules (see the `use_rules` + * hook in setup.php). + * + * @param array $params + * @return array> + */ +function plugin_moreoptions_getRuleActions(array $params = []): array +{ + return EscalationRule::getRuleActions($params); +} diff --git a/psalm.xml b/psalm.xml index cad0abc..351df00 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,6 +1,7 @@ diff --git a/public/css/moreoptions.scss b/public/css/moreoptions.scss index 107bc64..865bcb4 100644 --- a/public/css/moreoptions.scss +++ b/public/css/moreoptions.scss @@ -268,3 +268,25 @@ gap: 0.25rem; } } + +// "Review rules" page (see escalation_rules.html.twig): fixed column widths, so that every rule type +// table lines up the same way, and the field dropdown fills its column. +.moreoptions-escalation-rules { + table { + table-layout: fixed; + } + + // Name, Active, Field, Action type, Value + $column-widths: 30%, 8%, 27%, 12%, 23%; + @each $width in $column-widths { + th:nth-child(#{index($column-widths, $width)}) { + width: $width; + } + } + + // Active + th:nth-child(2), + td:nth-child(2) { + text-align: center; + } +} diff --git a/setup.php b/setup.php index dd06f8c..903b8c6 100644 --- a/setup.php +++ b/setup.php @@ -37,6 +37,7 @@ use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Controller; use GlpiPlugin\Moreoptions\Escalation; +use GlpiPlugin\Moreoptions\EscalationRule; use GlpiPlugin\Moreoptions\Group_Link; /** @phpstan-ignore theCodingMachineSafe.function (safe to assume this isn't already defined) */ @@ -135,6 +136,25 @@ function plugin_init_moreoptions(): void $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][ChangeTask::class] = Controller::assignTechnicianFromTask(...); $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][ProblemTask::class] = Controller::assignTechnicianFromTask(...); + + // "Escalate to group" rule action (see EscalationRule). The PRE_ITEM_UPDATE hooks of tickets, + // changes and problems are already taken above: only one callback per itemtype is possible, so + // the existing one is called first, then the escalation one. + $PLUGIN_HOOKS[Hooks::USE_RULES]['moreoptions'] = EscalationRule::getRuleClasses(); + + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + $PLUGIN_HOOKS[Hooks::PRE_ITEM_ADD]['moreoptions'][$itemtype] = EscalationRule::dropFromUserInput(...); + + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][$itemtype] = EscalationRule::escalateAfterAdd(...); + + $pre_item_update = $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['moreoptions'][$itemtype]; + $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['moreoptions'][$itemtype] = static function (CommonDBTM $item) use ($pre_item_update): void { + $pre_item_update($item); + EscalationRule::dropFromUserInput($item); + }; + + $PLUGIN_HOOKS[Hooks::POST_PREPAREUPDATE]['moreoptions'][$itemtype] = EscalationRule::escalateBeforeUpdate(...); + } } /** diff --git a/src/EscalationRule.php b/src/EscalationRule.php new file mode 100644 index 0000000..e94ca48 --- /dev/null +++ b/src/EscalationRule.php @@ -0,0 +1,451 @@ +> + */ + public static function getRuleClasses(): array + { + return [RuleTicket::class, RuleChange::class, RuleProblem::class]; + } + + /** + * Called by the `getRuleActions` plugin hook (see hook.php). + * + * @param array $params + * @return array> + */ + public static function getRuleActions(array $params): array + { + if (!in_array($params['rule_itemtype'] ?? '', self::getRuleClasses(), true)) { + return []; + } + + return [ + self::ACTION_FIELD => [ + 'name' => __('Escalate to group', 'moreoptions'), + 'type' => 'dropdown', + 'table' => Group::getTable(), + 'condition' => ['is_assign' => 1], + 'force_actions' => ['assign'], + ], + ]; + } + + /** + * Hooked on {@link \Glpi\Plugin\Hooks::PRE_ITEM_ADD} / {@link \Glpi\Plugin\Hooks::PRE_ITEM_UPDATE} + * for tickets, changes and problems, which run before the rules. The escalation group may only + * come from the rules: drop it from the user input, otherwise anyone able to update an item could + * escalate it. + */ + public static function dropFromUserInput(CommonDBTM $item): void + { + if (is_array($item->input)) { + unset($item->input[self::ACTION_FIELD]); + } + } + + /** + * Hooked on {@link \Glpi\Plugin\Hooks::ITEM_ADD} for tickets, changes and problems: called once + * the actors of the new item are saved. + */ + public static function escalateAfterAdd(CommonDBTM $item): void + { + if ($item instanceof CommonITILObject) { + self::escalate($item); + } + } + + /** + * Hooked on {@link \Glpi\Plugin\Hooks::POST_PREPAREUPDATE} for tickets, changes and problems: + * called after the rules, on every update. ITEM_UPDATE would be too late (the actors sent by the + * form are saved after it, adding back the previous group and technicians) and is skipped when no + * column of the item changes (e.g. a rule matching on an actor change only). + * + * The item is escalated right away, then the input of the update is aligned on the escalation + * result, otherwise the update would undo it. + */ + public static function escalateBeforeUpdate(CommonDBTM $item): void + { + if (!$item instanceof CommonITILObject) { + return; + } + + $groups_id = self::escalate($item); + if ($groups_id === null || !is_array($item->input)) { + return; + } + + // Assigned groups: only the target one remains. + $item->input['_groups_id_assign'] = [$groups_id]; + unset($item->input['_additional_groups_assigns'], $item->input['_groups_id_assign_deleted']); + + // Assigned technicians: dropped by the escalation when the entity says so. + $config = Config::getConfig((int) $item->fields['entities_id']); + if ((int) ($config->fields['escalate_remove_technician'] ?? 0) === 1) { + $item->input['_users_id_assign'] = []; + unset( + $item->input['_additional_assigns'], + $item->input['_users_id_assign_deleted'], + $item->input['_users_id_assign_notif'], + ); + } + + // Status: may have been changed by the escalation. Also set on the fields, so that the update + // does not see (and log) it as a change. + $escalated = getItemForItemtype($item::class); + if ($escalated instanceof CommonITILObject && $escalated->getFromDB($item->getID())) { + $item->fields['status'] = $escalated->fields['status']; + if (array_key_exists('status', $item->input)) { + $item->input['status'] = $escalated->fields['status']; + } + } + } + + /** + * Escalates the item to the group set by an "Escalate to group" rule action, if any. + * + * When the item cannot be escalated (escalation not enabled for its entity, or see + * Escalation::getEscalationBlocker()), the reason goes to the GLPI events log: there is nobody to + * report it to when the rules run from the mail collector. + * + * @return int|null The group the item was escalated to, null when it was not + */ + private static function escalate(CommonITILObject $item): ?int + { + if (!is_array($item->input) || !isset($item->input[self::ACTION_FIELD])) { + return null; + } + + $groups_id = (int) $item->input[self::ACTION_FIELD]; + unset($item->input[self::ACTION_FIELD]); + + $blocker = Escalation::isEnabledFor($item) + ? Escalation::getEscalationBlocker($item, $groups_id) + : __('Escalation is not enabled for the entity of the item.', 'moreoptions'); + if ($blocker !== null) { + Event::log( + $item->getID(), + strtolower($item::class), + 3, + $item instanceof Ticket ? 'tracking' : 'maintain', + sprintf( + __('The "%1$s" rule action did not escalate the item to the group "%2$s": %3$s', 'moreoptions'), + __('Escalate to group', 'moreoptions'), + Dropdown::getDropdownName(Group::getTable(), $groups_id), + $blocker, + ), + ); + return null; + } + + $config = Config::getConfig((int) $item->fields['entities_id']); + + $escalation = new Escalation(); + $escalated = $escalation->add([ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $groups_id, + 'content' => __('Escalated by a business rule.', 'moreoptions'), + 'is_private' => (int) ($config->fields['escalade_is_private_by_default'] ?? 0), + ]); + + return $escalated !== false ? $groups_id : null; + } + + /** + * Whether the current user can see the rules the "Review rules" page lists. + */ + public static function canManageRules(): bool + { + foreach (self::getRuleClasses() as $rule_class) { + if ($rule_class::canView()) { + return true; + } + } + + return false; + } + + /** + * Whether the "Review rules" page has a rule to switch to "Escalate to group": shows its link in + * the entity configuration (see escalation_rules_banner.html.twig). + */ + public static function hasRulesToReview(): bool + { + /** @var DBmysql $DB */ + global $DB; + + $criteria = self::getGroupAssignActionsCriteria([self::CORE_FIELD]); + if ($criteria === null) { + return false; + } + + $row = $DB->request(['COUNT' => 'cpt'] + $criteria)->current(); + + return (int) ($row['cpt'] ?? 0) > 0; + } + + /** + * The "Assign" actions on a technician group (core "Technician group" or "Escalate to group") + * of the rules the current user can see, sorted by rule type and ranking. + * + * @return array, rule_type: string, rule_link: string, is_active: bool, group: string, can_update: bool}> + */ + public static function getGroupAssignActions(): array + { + /** @var DBmysql $DB */ + global $DB; + + $criteria = self::getGroupAssignActionsCriteria([self::CORE_FIELD, self::ACTION_FIELD]); + if ($criteria === null) { + return []; + } + + $rules_table = Rule::getTable(); + $actions_table = RuleAction::getTable(); + + $iterator = $DB->request([ + 'SELECT' => [ + $actions_table . '.id', + $actions_table . '.field', + $actions_table . '.value', + $rules_table . '.id AS rules_id', + $rules_table . '.sub_type', + $rules_table . '.is_active', + ], + 'ORDER' => [$rules_table . '.sub_type', $rules_table . '.ranking', $actions_table . '.id'], + ] + $criteria); + + $actions = []; + foreach ($iterator as $row) { + $rule = getItemForItemtype($row['sub_type']); + if (!$rule instanceof Rule) { + continue; + } + + // Also loads the rule, for getLink(). + $can_update = $rule->can((int) $row['rules_id'], UPDATE); + + $actions[] = [ + 'id' => (int) $row['id'], + 'field' => (string) $row['field'], + 'rule_class' => $rule::class, + 'rule_type' => $rule->getTitle(), + 'rule_link' => $rule->getLink(), + 'is_active' => (int) $row['is_active'] === 1, + 'group' => Dropdown::getDropdownName(Group::getTable(), (int) $row['value']), + 'can_update' => $can_update, + ]; + } + + return $actions; + } + + /** + * The "Assign" actions on the given fields of the rules the current user can see, null when they + * cannot see any rule. + * + * @param array $fields + * @return array|null + */ + private static function getGroupAssignActionsCriteria(array $fields): ?array + { + $rule_classes = array_values(array_filter( + self::getRuleClasses(), + static fn(string $rule_class): bool => $rule_class::canView(), + )); + if ($rule_classes === []) { + return null; + } + + $rules_table = Rule::getTable(); + $actions_table = RuleAction::getTable(); + + return [ + 'FROM' => $actions_table, + 'INNER JOIN' => [ + $rules_table => [ + 'ON' => [ + $actions_table => 'rules_id', + $rules_table => 'id', + ], + ], + ], + 'WHERE' => [ + $actions_table . '.field' => $fields, + $actions_table . '.action_type' => 'assign', + $rules_table . '.sub_type' => $rule_classes, + ] + getEntitiesRestrictCriteria($rules_table, '', '', true), + ]; + } + + /** + * Applies the fields chosen on the "Review rules" page: switches each "Assign" technician group + * action whose field changed to "Escalate to group" or back to the core "Technician group". + * + * @param array $fields Rule action id => self::CORE_FIELD or self::ACTION_FIELD + * @return array{updated: int, failed: int} + */ + public static function switchActions(array $fields): array + { + $result = ['updated' => 0, 'failed' => 0]; + foreach ($fields as $ruleactions_id => $field) { + $action = new RuleAction(); + if (!$action->getFromDB((int) $ruleactions_id) || !in_array($field, [self::CORE_FIELD, self::ACTION_FIELD], true)) { + $result['failed']++; + continue; + } + + if ($action->fields['field'] === $field) { + continue; + } + + if (self::switchAction($action, $field)) { + $result['updated']++; + } else { + $result['failed']++; + } + } + + return $result; + } + + private static function switchAction(RuleAction $action, string $field): bool + { + if ( + $action->fields['action_type'] !== 'assign' + || !in_array($action->fields['field'], [self::CORE_FIELD, self::ACTION_FIELD], true) + ) { + return false; + } + + $rule = Rule::getRuleObjectByID((int) $action->fields['rules_id']); + if ( + $rule === null + || !in_array($rule::class, self::getRuleClasses(), true) + || !$rule->can((int) $action->fields['rules_id'], UPDATE) + || !$action->update(['id' => $action->getID(), 'field' => $field]) + ) { + return false; + } + + // As the core does when an action is added or removed (see RuleAction::post_addItem()). + $rule->update(['id' => $rule->getID(), 'date_mod' => $_SESSION['glpi_currenttime']]); + + return true; + } + + /** + * Renders the "Review rules" page (see front/escalation_rules.php). + */ + public static function showRulesList(): void + { + // One table per rule type, as the rules lists. + $actions = self::getGroupAssignActions(); + + $actions_by_type = []; + foreach ($actions as $action) { + $actions_by_type[$action['rule_class']] ??= ['title' => $action['rule_type'], 'actions' => []]; + $actions_by_type[$action['rule_class']]['actions'][] = $action; + } + + TemplateRenderer::getInstance()->display('@moreoptions/escalation_rules.html.twig', [ + 'actions_by_type' => $actions_by_type, + 'can_update_any' => in_array(true, array_column($actions, 'can_update'), true), + 'field_labels' => [ + self::CORE_FIELD => __('Technician group'), + self::ACTION_FIELD => __('Escalate to group', 'moreoptions'), + ], + ]); + } + + /** + * Once the plugin is uninstalled, the "Escalate to group" rule actions would be unknown, and + * their rules would silently stop assigning a group: switch them back to the core + * "Technician group". + */ + public static function uninstall(Migration $migration): void + { + /** @var DBmysql $DB */ + global $DB; + + $migration->displayMessage('Switching "Escalate to group" rule actions back to "Technician group"'); + $DB->update( + RuleAction::getTable(), + ['field' => self::CORE_FIELD], + ['field' => self::ACTION_FIELD], + ); + } +} diff --git a/templates/config.html.twig b/templates/config.html.twig index 2958e73..adeba3e 100644 --- a/templates/config.html.twig +++ b/templates/config.html.twig @@ -144,6 +144,10 @@ {% endfor %} {% endfor %} + + {% if tab.id == 'escalate' %} + {{ include('@moreoptions/escalation_rules_banner.html.twig') }} + {% endif %} {% endfor %} diff --git a/templates/escalation_rules.html.twig b/templates/escalation_rules.html.twig new file mode 100644 index 0000000..1341d36 --- /dev/null +++ b/templates/escalation_rules.html.twig @@ -0,0 +1,104 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # ------------------------------------------------------------------------- + #} + +
+ + + {% if actions_by_type is empty %} +
+ {{ __('No rule assigns a technician group.', 'moreoptions') }} +
+ {% endif %} + + {% for rule_class, rule_type in actions_by_type %} + {% set entries = [] %} + {% for action in rule_type.actions %} + {% set field %} + {% if action.can_update %} + {{ call('Dropdown::showFromArray', ['fields[' ~ action.id ~ ']', field_labels, { + 'value': action.field, + 'width': '100%', + 'display': false, + }])|raw }} + {% else %} + {{ field_labels[action.field] }} + {% endif %} + {% endset %} + + {% set is_active %} + + {% endset %} + + {% set entries = entries|merge([{ + 'name': action.rule_link, + 'is_active': is_active, + 'field': field, + 'action_type': __('Assign'), + 'value': action.group, + }]) %} + {% endfor %} + +
+ {{ include('components/datatable.html.twig', { + 'datatable_id': 'moreoptions_escalation_rules_' ~ loop.index, + 'table_class_style': 'table-hover table-vcenter card-table', + 'nofilter': true, + 'nosort': true, + 'super_header': rule_type.title, + 'columns': { + 'name': __('Name'), + 'is_active': __('Active'), + 'field': _n('Field', 'Fields', 1), + 'action_type': __('Action type'), + 'value': __('Value'), + }, + 'formatters': { + 'name': 'raw_html', + 'is_active': 'raw_html', + 'field': 'raw_html', + }, + 'entries': entries, + 'total_number': entries|length, + 'filtered_number': entries|length, + }, with_context = false) }} +
+ {% endfor %} + + {% if can_update_any %} +
+ +
+ {% endif %} +
diff --git a/templates/escalation_rules_banner.html.twig b/templates/escalation_rules_banner.html.twig new file mode 100644 index 0000000..57f507c --- /dev/null +++ b/templates/escalation_rules_banner.html.twig @@ -0,0 +1,48 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # ------------------------------------------------------------------------- + #} + +{# + # Shown at the bottom of the "Escalate" tab of the entity configuration (see config.html.twig), when + # some rules assign a technician group: link to the page that switches them to "Escalate to group". + #} + +{% if call('GlpiPlugin\\Moreoptions\\EscalationRule::hasRulesToReview') %} +
+ +
+ {{ __('You have business rules that currently assign a technician to a ticket, change, or problem. You can update them here using the “Escalate to group” action.', 'moreoptions') }} +
+ + + {{ __('Check rules', 'moreoptions') }} + +
+{% endif %} diff --git a/tests/Units/EscalationRuleTest.php b/tests/Units/EscalationRuleTest.php new file mode 100644 index 0000000..28bc43a --- /dev/null +++ b/tests/Units/EscalationRuleTest.php @@ -0,0 +1,565 @@ +, class-string}> + */ + public static function itemtypeProvider(): iterable + { + yield Ticket::class => [Ticket::class, RuleTicket::class]; + yield Change::class => [Change::class, RuleChange::class]; + yield Problem::class => [Problem::class, RuleProblem::class]; + } + + /** + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testActionIsAvailableInRules(string $itemtype, string $rule_class): void + { + $rule = getItemForItemtype($rule_class); + $this->assertInstanceOf(RuleCommonITILObject::class, $rule); + + $action = $rule->getAllActions()[EscalationRule::ACTION_FIELD] ?? null; + $this->assertIsArray($action); + $this->assertSame(['assign'], $action['force_actions'] ?? null); + $this->assertSame(Group::getTable(), $action['table'] ?? null); + } + + public function testActionIsNotAvailableInOtherRules(): void + { + $this->assertArrayNotHasKey(EscalationRule::ACTION_FIELD, (new RuleRight())->getAllActions()); + } + + /** + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationOnAdd(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->configureEscalation($entities_id, ['escalate_is_active' => 1]); + + $source_group = $this->createGroup($entities_id, 'Source group'); + $target_group = $this->createGroup($entities_id, 'Target group'); + $this->createEscalationRule($rule_class, RuleCommonITILObject::ONADD, $target_group); + + $item = $this->createItem($itemtype, [ + 'name' => 'Please escalate-me', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => $source_group->getID(), + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + // The target group replaced the group assigned on creation + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + + $escalations = (new Escalation())->find(['itemtype' => $itemtype, 'items_id' => $item->getID()]); + $this->assertCount(1, $escalations); + $escalation = reset($escalations); + $this->assertSame($target_group->getID(), (int) $escalation['groups_id']); + $this->assertSame('[' . $source_group->getID() . ']', $escalation['groups_ids_source']); + } + + /** + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationOnUpdate(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->configureEscalation($entities_id, ['escalate_is_active' => 1]); + + $source_group = $this->createGroup($entities_id, 'Source group'); + $target_group = $this->createGroup($entities_id, 'Target group'); + $this->createEscalationRule($rule_class, RuleCommonITILObject::ONUPDATE, $target_group); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => $source_group->getID(), + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + $this->assertSame([$source_group->getID()], $this->getAssignedGroupIds($item)); + + $this->updateItem($itemtype, $item->getID(), ['name' => 'Please escalate-me']); + + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + $this->assertSame(1, countElementsInTable(Escalation::getTable(), [ + 'itemtype' => $itemtype, + 'items_id' => $item->getID(), + ])); + } + + /** + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testNoEscalationWhenDisabledForEntity(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->configureEscalation($entities_id, ['escalate_is_active' => 0]); + + $source_group = $this->createGroup($entities_id, 'Source group'); + $target_group = $this->createGroup($entities_id, 'Target group'); + $this->createEscalationRule($rule_class, RuleCommonITILObject::ONADD, $target_group); + + $item = $this->createItem($itemtype, [ + 'name' => 'Please escalate-me', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => $source_group->getID(), + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + // Nothing happened... + $this->assertSame([$source_group->getID()], $this->getAssignedGroupIds($item)); + $this->assertSame(0, countElementsInTable(Escalation::getTable(), [ + 'itemtype' => $itemtype, + 'items_id' => $item->getID(), + ])); + + // ... and the events log says why + $events = (new Event())->find([ + 'type' => strtolower($itemtype), + 'items_id' => $item->getID(), + 'message' => ['LIKE', '%Escalation is not enabled for the entity of the item.%'], + ]); + $this->assertCount(1, $events); + $this->assertStringContainsString('Target group', reset($events)['message']); + } + + /** + * Saving the item form sends back its current actors and status: they must not undo the escalation. + * + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationOnUpdateFromForm(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $status_field = 'escalade_status_after_escalation_' . strtolower($itemtype); + $this->configureEscalation($entities_id, [ + 'escalate_is_active' => 1, + 'escalate_remove_technician' => 1, + $status_field => CommonITILObject::WAITING, + ]); + + $source_group = $this->createGroup($entities_id, 'Source group'); + $target_group = $this->createGroup($entities_id, 'Target group'); + $technician_id = getItemByTypeName(User::class, 'tech', true); + $this->createEscalationRule($rule_class, RuleCommonITILObject::ONUPDATE, $target_group); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => $source_group->getID(), + '_users_id_assign' => $technician_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + $this->assertTrue($item->getFromDB($item->getID())); + $status_before = (int) $item->fields['status']; + $this->assertNotSame(CommonITILObject::WAITING, $status_before); + + $this->assertTrue($item->update([ + 'id' => $item->getID(), + 'name' => 'Please escalate-me', + 'status' => $status_before, + '_actors' => [ + 'requester' => [], + 'observer' => [], + 'assign' => [ + ['itemtype' => Group::class, 'items_id' => $source_group->getID()], + ['itemtype' => User::class, 'items_id' => $technician_id, 'use_notification' => 1, 'alternative_email' => ''], + ], + ], + ])); + + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + $this->assertSame([], $this->getAssignedUserIds($item)); + $this->assertTrue($item->getFromDB($item->getID())); + $this->assertSame(CommonITILObject::WAITING, (int) $item->fields['status']); + } + + /** + * A rule matching on an actor change only escalates the item, although none of its columns changes. + * + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationOnActorChangeOnly(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->configureEscalation($entities_id, ['escalate_is_active' => 1]); + + $target_group = $this->createGroup($entities_id, 'Target group'); + $technician_id = getItemByTypeName(User::class, 'tech', true); + $this->createRule( + (new RuleBuilder('Escalation on technician', $rule_class)) + ->setEntity(0) + ->setCondtion(RuleCommonITILObject::ONUPDATE) + ->addCriteria('_users_id_assign', Rule::PATTERN_IS, $technician_id) + ->addAction('assign', EscalationRule::ACTION_FIELD, $target_group->getID()), + ); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + $this->assertTrue($item->update([ + 'id' => $item->getID(), + '_actors' => [ + 'requester' => [], + 'observer' => [], + 'assign' => [ + ['itemtype' => User::class, 'items_id' => $technician_id, 'use_notification' => 1, 'alternative_email' => ''], + ], + ], + ])); + + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + $this->assertSame(1, countElementsInTable(Escalation::getTable(), [ + 'itemtype' => $itemtype, + 'items_id' => $item->getID(), + ])); + } + + /** + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationCannotComeFromUserInput(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $target_group = $this->createGroup($entities_id, 'Target group'); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + EscalationRule::ACTION_FIELD => $target_group->getID(), + ], [EscalationRule::ACTION_FIELD]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + $this->updateItem($itemtype, $item->getID(), [ + 'name' => 'Test escalation updated', + EscalationRule::ACTION_FIELD => $target_group->getID(), + ], [EscalationRule::ACTION_FIELD]); + + $this->assertSame([], $this->getAssignedGroupIds($item)); + $this->assertSame(0, countElementsInTable(Escalation::getTable(), [ + 'itemtype' => $itemtype, + 'items_id' => $item->getID(), + ])); + } + + public function testSwitchAction(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $group = $this->createGroup($entities_id, 'Rule group'); + $rule = $this->createRule( + (new RuleBuilder('Technician group rule', RuleTicket::class)) + ->setEntity(0) + ->addCriteria('name', Rule::PATTERN_CONTAIN, 'anything') + ->addAction('assign', EscalationRule::CORE_FIELD, $group->getID()) + ->addAction('append', EscalationRule::CORE_FIELD, $group->getID()), + ); + + $assign = $this->getRuleAction($rule, 'assign'); + $append = $this->getRuleAction($rule, 'append'); + + // Only the "Assign" action is listed + $listed = array_column(EscalationRule::getGroupAssignActions(), 'field', 'id'); + $this->assertSame(EscalationRule::CORE_FIELD, $listed[$assign->getID()] ?? null); + $this->assertArrayNotHasKey($append->getID(), $listed); + + // Switch to "Escalate to group", then back + $this->assertSame(['updated' => 1, 'failed' => 0], EscalationRule::switchActions([$assign->getID() => EscalationRule::ACTION_FIELD])); + $this->assertTrue($assign->getFromDB($assign->getID())); + $this->assertSame(EscalationRule::ACTION_FIELD, $assign->fields['field']); + $this->assertSame((string) $group->getID(), (string) $assign->fields['value']); + + $this->assertSame(['updated' => 1, 'failed' => 0], EscalationRule::switchActions([$assign->getID() => EscalationRule::CORE_FIELD])); + $this->assertTrue($assign->getFromDB($assign->getID())); + $this->assertSame(EscalationRule::CORE_FIELD, $assign->fields['field']); + + // An "Add" action cannot be switched, nor can an action be switched to any other field + $this->assertSame(['updated' => 0, 'failed' => 1], EscalationRule::switchActions([$append->getID() => EscalationRule::ACTION_FIELD])); + $this->assertSame(['updated' => 0, 'failed' => 1], EscalationRule::switchActions([$assign->getID() => 'name'])); + $this->assertTrue($append->getFromDB($append->getID())); + $this->assertSame(EscalationRule::CORE_FIELD, $append->fields['field']); + $this->assertTrue($assign->getFromDB($assign->getID())); + $this->assertSame(EscalationRule::CORE_FIELD, $assign->fields['field']); + } + + public function testSwitchActions(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $group = $this->createGroup($entities_id, 'Rule group'); + $actions = []; + foreach (['First', 'Second'] as $name) { + $rule = $this->createRule( + (new RuleBuilder($name . ' rule', RuleTicket::class)) + ->setEntity(0) + ->addCriteria('name', Rule::PATTERN_CONTAIN, 'anything') + ->addAction('assign', EscalationRule::CORE_FIELD, $group->getID()), + ); + $actions[] = $this->getRuleAction($rule, 'assign'); + } + + // Only the changed actions are counted, unknown ones fail + $result = EscalationRule::switchActions([ + $actions[0]->getID() => EscalationRule::ACTION_FIELD, + $actions[1]->getID() => EscalationRule::CORE_FIELD, + 0 => EscalationRule::ACTION_FIELD, + ]); + $this->assertSame(['updated' => 1, 'failed' => 1], $result); + + $this->assertTrue($actions[0]->getFromDB($actions[0]->getID())); + $this->assertSame(EscalationRule::ACTION_FIELD, $actions[0]->fields['field']); + $this->assertTrue($actions[1]->getFromDB($actions[1]->getID())); + $this->assertSame(EscalationRule::CORE_FIELD, $actions[1]->fields['field']); + } + + public function testUninstallSwitchesActionsBackToTechnicianGroup(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $group = $this->createGroup($entities_id, 'Rule group'); + $rule = $this->createEscalationRule(RuleTicket::class, RuleCommonITILObject::ONADD, $group); + $action = $this->getRuleAction($rule, 'assign'); + $this->assertSame(EscalationRule::ACTION_FIELD, $action->fields['field']); + + EscalationRule::uninstall(new Migration(PLUGIN_MOREOPTIONS_VERSION)); + + $this->assertTrue($action->getFromDB($action->getID())); + $this->assertSame(EscalationRule::CORE_FIELD, $action->fields['field']); + $this->assertSame((string) $group->getID(), (string) $action->fields['value']); + $this->assertSame('assign', $action->fields['action_type']); + } + + public function testRulesPageAndBanner(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $entity = getItemByTypeName(Entity::class, '_test_root_entity'); + $group = $this->createGroup($entities_id, 'Rule group'); + + // No rule assigns a technician group: no link to the rules page + $this->assertFalse(EscalationRule::hasRulesToReview()); + $this->assertCount(0, $this->getRulesPageLinks($entity)); + + $rule = $this->createRule( + (new RuleBuilder('Technician group rule', RuleTicket::class)) + ->setEntity(0) + ->addCriteria('name', Rule::PATTERN_CONTAIN, 'anything') + ->addAction('assign', EscalationRule::CORE_FIELD, $group->getID()), + ); + $action = $this->getRuleAction($rule, 'assign'); + $this->assertTrue(EscalationRule::hasRulesToReview()); + + // The rules page lists the action, with its field dropdown, in a single form with one save button + ob_start(); + EscalationRule::showRulesList(); + $crawler = new Crawler(ob_get_clean()); + + $this->assertCount(1, $crawler->filter('form')); + $this->assertCount(1, $crawler->filter('form button[name="update"]')); + $select = $crawler->filter('select[name="fields[' . $action->getID() . ']"]'); + $this->assertCount(1, $select); + $this->assertSame(EscalationRule::CORE_FIELD, $select->filter('option[selected]')->attr('value')); + $this->assertCount(1, $select->filter('option[value="' . EscalationRule::ACTION_FIELD . '"]')); + + // The entity configuration links to it + $this->assertCount(1, $this->getRulesPageLinks($entity)); + } + + private function getRulesPageLinks(Entity $entity): Crawler + { + ob_start(); + Config::showForEntity($entity); + + return (new Crawler(ob_get_clean()))->filter('a[href$="/plugins/moreoptions/front/escalation_rules.php"]'); + } + + /** + * @param array $fields + */ + private function configureEscalation(int $entities_id, array $fields): void + { + $config = Config::getConfig($entities_id, false); + if ($config->isNewItem()) { + $this->createTestConfig(['entities_id' => $entities_id] + $fields); + } else { + $this->updateTestConfig($config, $fields); + } + } + + /** + * @return array + */ + private function getAssignedUserIds(CommonITILObject $item): array + { + $user_link = getItemForItemtype($item->userlinkclass); + $this->assertInstanceOf(CommonITILActor::class, $user_link); + + return array_map( + static fn(array $row): int => (int) $row['users_id'], + array_values($user_link->find([ + $item->getForeignKeyField() => $item->getID(), + 'type' => CommonITILActor::ASSIGN, + ], ['id ASC'])), + ); + } + + /** + * @param class-string $rule_class + */ + private function createEscalationRule(string $rule_class, int $condition, Group $group): Rule + { + $builder = (new RuleBuilder('Escalation rule', $rule_class)) + ->setEntity(0) + ->setCondtion($condition) + ->addCriteria('name', Rule::PATTERN_CONTAIN, 'escalate-me') + ->addAction('assign', EscalationRule::ACTION_FIELD, $group->getID()); + + return $this->createRule($builder); + } + + private function getRuleAction(Rule $rule, string $action_type): RuleAction + { + $action = new RuleAction(); + $this->assertTrue($action->getFromDBByCrit(['rules_id' => $rule->getID(), 'action_type' => $action_type])); + + return $action; + } + + private function createGroup(int $entities_id, string $name): Group + { + $group = $this->createItem(Group::class, [ + 'name' => $name, + 'entities_id' => $entities_id, + 'is_recursive' => 1, + 'is_assign' => 1, + ]); + $this->assertInstanceOf(Group::class, $group); + + return $group; + } + + /** + * @return array + */ + private function getAssignedGroupIds(CommonITILObject $item): array + { + $group_link = getItemForItemtype($item->grouplinkclass); + $this->assertInstanceOf(CommonITILActor::class, $group_link); + + return array_map( + static fn(array $row): int => (int) $row['groups_id'], + array_values($group_link->find([ + $item->getForeignKeyField() => $item->getID(), + 'type' => CommonITILActor::ASSIGN, + ], ['id ASC'])), + ); + } +} From 7c3dd46370871bb530393b32e86d3130bf33c4c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Langlois=20Ga=C3=ABtan?= <64356364+MyvTsv@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:03:07 +0200 Subject: [PATCH 9/9] feat(group): add escalation hierarchy editor between groups (#28) * feat(group): add escalation hierarchy editor between groups * Move config page and options * fix ci * fix ci * feat(escalation): filter escalation groups with the escalation tree (#31) * feat(escalation): filter escalation groups with the escalation tree * Update CHANGELOG.md * lint --------- Co-authored-by: Lainow --- CHANGELOG.md | 2 + ajax/group_link.php | 44 + locales/en_GB.po | 415 +++++++- locales/moreoptions.pot | 382 +++++++- public/css/moreoptions.scss | 157 ++- public/js/escalation_graph.js | 911 ++++++++++++++++++ setup.php | 4 +- src/Config.php | 151 ++- src/Escalation.php | 60 +- .../Action/AbstractTreeAction.php | 63 ++ .../Action/AbstractViewAction.php | 47 + src/EscalationTree/Action/AddLinkAction.php | 79 ++ src/EscalationTree/Action/AddNodeAction.php | 59 ++ src/EscalationTree/Action/ClearAction.php | 53 + .../Action/DeleteLinkAction.php | 63 ++ .../Action/DeleteSelectionAction.php | 60 ++ .../Action/DrawingStrategyAction.php | 57 ++ src/EscalationTree/Action/LinkAction.php | 75 ++ .../Action/OrientationAction.php | 53 + .../Action/RemoveNodeAction.php | 54 ++ src/EscalationTree/Action/RenderAction.php | 53 + src/EscalationTree/Action/ResetAction.php | 55 ++ .../Action/SelectLinkAction.php | 57 ++ .../Action/SelectNodeAction.php | 56 ++ .../Action/SetLinkTypeAction.php | 60 ++ .../Action/TreeActionRegistry.php | 95 ++ src/EscalationTree/EscalationGraph.php | 238 +++++ src/EscalationTree/EscalationLink.php | 155 +++ src/EscalationTree/EscalationTree.php | 570 +++++++++++ src/EscalationTree/GroupNode.php | 136 +++ src/EscalationTree/TreeEditor.php | 380 ++++++++ src/EscalationTree/TreeLayout.php | 288 ++++++ src/EscalationTree/TreeView.php | 289 ++++++ src/Group_Link.php | 495 +++++++++- src/LinkStrategy/AbstractLinkStrategy.php | 25 +- src/LinkStrategy/BasicLink.php | 9 +- src/LinkStrategy/InheritedLink.php | 9 +- src/LinkStrategy/LinkStrategyEnum.php | 35 +- src/LinkStrategy/NoneLink.php | 5 - .../components/group_link/editor.html.twig | 163 ++++ .../group_link/group_list.html.twig | 124 +++ .../group_link/group_panel.html.twig | 156 +++ .../components/group_link/legend.html.twig | 70 ++ .../group_link/link_panel.html.twig | 94 ++ .../components/group_link/macros.html.twig | 107 ++ templates/config.html.twig | 82 +- templates/escalation_form.html.twig | 12 +- templates/group_link.html.twig | 62 ++ tests/EscalationTestCase.php | 142 +++ tests/Units/ConfigTest.php | 29 + tests/Units/EscalationGraphTest.php | 77 ++ tests/Units/EscalationLinkTest.php | 98 ++ tests/Units/EscalationTest.php | 164 ++++ tests/Units/EscalationTreeTest.php | 316 ++++++ tests/Units/Group_LinkTest.php | 395 ++++++++ tests/Units/TreeEditorTest.php | 798 +++++++++++++++ tests/Units/TreeLayoutTest.php | 207 ++++ tests/bootstrap.php | 1 + 58 files changed, 8769 insertions(+), 127 deletions(-) create mode 100644 ajax/group_link.php create mode 100644 public/js/escalation_graph.js create mode 100644 src/EscalationTree/Action/AbstractTreeAction.php create mode 100644 src/EscalationTree/Action/AbstractViewAction.php create mode 100644 src/EscalationTree/Action/AddLinkAction.php create mode 100644 src/EscalationTree/Action/AddNodeAction.php create mode 100644 src/EscalationTree/Action/ClearAction.php create mode 100644 src/EscalationTree/Action/DeleteLinkAction.php create mode 100644 src/EscalationTree/Action/DeleteSelectionAction.php create mode 100644 src/EscalationTree/Action/DrawingStrategyAction.php create mode 100644 src/EscalationTree/Action/LinkAction.php create mode 100644 src/EscalationTree/Action/OrientationAction.php create mode 100644 src/EscalationTree/Action/RemoveNodeAction.php create mode 100644 src/EscalationTree/Action/RenderAction.php create mode 100644 src/EscalationTree/Action/ResetAction.php create mode 100644 src/EscalationTree/Action/SelectLinkAction.php create mode 100644 src/EscalationTree/Action/SelectNodeAction.php create mode 100644 src/EscalationTree/Action/SetLinkTypeAction.php create mode 100644 src/EscalationTree/Action/TreeActionRegistry.php create mode 100644 src/EscalationTree/EscalationGraph.php create mode 100644 src/EscalationTree/EscalationLink.php create mode 100644 src/EscalationTree/EscalationTree.php create mode 100644 src/EscalationTree/GroupNode.php create mode 100644 src/EscalationTree/TreeEditor.php create mode 100644 src/EscalationTree/TreeLayout.php create mode 100644 src/EscalationTree/TreeView.php create mode 100644 templates/components/group_link/editor.html.twig create mode 100644 templates/components/group_link/group_list.html.twig create mode 100644 templates/components/group_link/group_panel.html.twig create mode 100644 templates/components/group_link/legend.html.twig create mode 100644 templates/components/group_link/link_panel.html.twig create mode 100644 templates/components/group_link/macros.html.twig create mode 100644 tests/EscalationTestCase.php create mode 100644 tests/Units/EscalationGraphTest.php create mode 100644 tests/Units/EscalationLinkTest.php create mode 100644 tests/Units/EscalationTreeTest.php create mode 100644 tests/Units/Group_LinkTest.php create mode 100644 tests/Units/TreeEditorTest.php create mode 100644 tests/Units/TreeLayoutTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index de82dd3..b8ec0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Add rector config - Implementation of the basic concept of escalation - Add the "Escalate to group" action to the ticket, change and problem business rules, and a page to switch the rules "Technician group" actions to it +- Add the escalation hierarchy between groups: graph editor in the "Escalation" tab of the groups, with basic links (limited to their entity) and inherited links (replicated in the child entities). Changing it requires the right to update the GLPI configuration +- Filter the groups of the "Escalate" form with the escalation tree ## [1.0.0-rc2] diff --git a/ajax/group_link.php b/ajax/group_link.php new file mode 100644 index 0000000..8c27133 --- /dev/null +++ b/ajax/group_link.php @@ -0,0 +1,44 @@ +display(); diff --git a/locales/en_GB.po b/locales/en_GB.po index a80c3e6..4e68414 100644 --- a/locales/en_GB.po +++ b/locales/en_GB.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-14 09:44+0000\n" -"PO-Revision-Date: 2026-09-14 09:44+0000\n" +"POT-Creation-Date: 2026-10-02 10:20+0000\n" +"PO-Revision-Date: 2026-10-02 10:20+0000\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: en_GB\n" @@ -17,6 +17,210 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: templates/escalation_button.html.twig templates/escalation_form.html.twig +#: src/Config.php:334 src/Config.php:403 +msgid "Escalate" +msgstr "Escalate" + +#: templates/components/segmented_control.html.twig +msgid "Follow the parent entity" +msgstr "Follow the parent entity" + +#: templates/components/group_link/legend.html.twig +msgid "Link to draw" +msgstr "Link to draw" + +#: templates/components/group_link/legend.html.twig +#, php-format +msgid "%s, replicated from a parent entity" +msgstr "%s, replicated from a parent entity" + +#: templates/components/group_link/group_list.html.twig +msgid "Search a group" +msgstr "Search a group" + +#: templates/components/group_link/group_list.html.twig +msgid "Drag onto the graph, or click, to place this group" +msgstr "Drag onto the graph, or click, to place this group" + +#: templates/components/group_link/group_list.html.twig +msgid "No group matches the search." +msgstr "No group matches the search." + +#: templates/components/group_link/group_list.html.twig +msgid "All the groups are placed." +msgstr "All the groups are placed." + +#: templates/components/group_link/editor.html.twig +msgid "History" +msgstr "History" + +#: templates/components/group_link/editor.html.twig +msgid "Orientation" +msgstr "Orientation" + +#: templates/components/group_link/editor.html.twig +msgid "Horizontal" +msgstr "Horizontal" + +#: templates/components/group_link/editor.html.twig +msgid "Vertical" +msgstr "Vertical" + +#: templates/components/group_link/editor.html.twig +msgid "Fit the graph to the view" +msgstr "Fit the graph to the view" + +#: templates/components/group_link/editor.html.twig +msgid "Remove all the links" +msgstr "Remove all the links" + +#: templates/components/group_link/editor.html.twig +msgid "" +"Remove the links of this entity, and restore the ones inherited from the " +"parent entities" +msgstr "" +"Remove the links of this entity, and restore the ones inherited from the " +"parent entities" + +#: templates/components/group_link/editor.html.twig +msgid "Remove all the links of the graph?" +msgstr "Remove all the links of the graph?" + +#: templates/components/group_link/editor.html.twig +msgid "" +"Remove all the links of this entity? The links inherited from the parent " +"entities are restored." +msgstr "" +"Remove all the links of this entity? The links inherited from the parent " +"entities are restored." + +#: templates/components/group_link/editor.html.twig +msgid "Drag from the circle of a group onto another group to link them." +msgstr "Drag from the circle of a group onto another group to link them." + +#: templates/components/group_link/editor.html.twig +msgid "Drag a group of the list onto the graph to place it." +msgstr "Drag a group of the list onto the graph to place it." + +#: templates/components/group_link/editor.html.twig +msgid "Right-click a group or a link for more actions." +msgstr "Right-click a group or a link for more actions." + +#: templates/components/group_link/editor.html.twig +msgid "Delete: removes the selection. Ctrl+Z / Ctrl+Y: undo / redo." +msgstr "Delete: removes the selection. Ctrl+Z / Ctrl+Y: undo / redo." + +#: templates/components/group_link/editor.html.twig +msgid "Drag the background to move around, scroll to zoom." +msgstr "Drag the background to move around, scroll to zoom." + +#: templates/components/group_link/editor.html.twig +msgid "Dashed links are inherited from a parent entity." +msgstr "Dashed links are inherited from a parent entity." + +#: templates/components/group_link/editor.html.twig +msgid "The links shown and saved are the ones of the active entity" +msgstr "The links shown and saved are the ones of the active entity" + +#: templates/components/group_link/editor.html.twig +#, php-format +msgid "%d group" +msgid_plural "%d groups" +msgstr[0] "%d group" +msgstr[1] "%d groups" + +#: templates/components/group_link/editor.html.twig +#, php-format +msgid "%d link" +msgid_plural "%d links" +msgstr[0] "%d link" +msgstr[1] "%d links" + +#: templates/components/group_link/group_panel.html.twig +msgid "Level" +msgstr "Level" + +#: templates/components/group_link/group_panel.html.twig +#: templates/group_link.html.twig +#, php-format +msgid "L%d" +msgstr "L%d" + +#: templates/components/group_link/group_panel.html.twig +msgid "Escalates to" +msgstr "Escalates to" + +#: templates/components/group_link/group_panel.html.twig +msgid "This group does not escalate to any group." +msgstr "This group does not escalate to any group." + +#: templates/components/group_link/group_panel.html.twig +msgid "Add a group to escalate to" +msgstr "Add a group to escalate to" + +#: templates/components/group_link/group_panel.html.twig +msgid "Receives escalations from" +msgstr "Receives escalations from" + +#: templates/components/group_link/group_panel.html.twig +msgid "No group escalates to it: it is a first level." +msgstr "No group escalates to it: it is a first level." + +#: templates/components/group_link/group_panel.html.twig +msgid "Add a group escalating to it" +msgstr "Add a group escalating to it" + +#: templates/components/group_link/group_panel.html.twig +msgid "Remove the group from the graph, with its links" +msgstr "Remove the group from the graph, with its links" + +#: templates/components/group_link/group_panel.html.twig +#: templates/components/group_link/link_panel.html.twig +msgid "Delete key" +msgstr "Delete key" + +#: templates/components/group_link/group_panel.html.twig +#, php-format +msgid "%1$s link replicated from the entity %2$s" +msgstr "%1$s link replicated from the entity %2$s" + +#: templates/components/group_link/group_panel.html.twig +#: templates/components/group_link/link_panel.html.twig +#: templates/group_link.html.twig +msgid "Delete the link" +msgstr "Delete the link" + +#: templates/components/group_link/group_panel.html.twig +#: templates/components/group_link/link_panel.html.twig +msgid "Link type" +msgstr "Link type" + +#: templates/components/group_link/link_panel.html.twig +msgid "Link" +msgstr "Link" + +#: templates/components/group_link/link_panel.html.twig +msgid "Escalates to the child group" +msgstr "Escalates to the child group" + +#: templates/components/group_link/link_panel.html.twig +msgid "Receives the escalations of the parent group" +msgstr "Receives the escalations of the parent group" + +#: templates/components/group_link/link_panel.html.twig +msgid "Deleting it here removes it from this entity and its child entities." +msgstr "Deleting it here removes it from this entity and its child entities." + +#: templates/components/group_link/link_panel.html.twig +#, php-format +msgid "Inherited from %s" +msgstr "Inherited from %s" + +#: templates/config.html.twig +msgid "Inherit" +msgstr "Inherit" + #: templates/config.html.twig msgid "" "The \"Use the technician's group\" option from Escalade is enabled and takes " @@ -25,63 +229,200 @@ msgstr "" "The \"Use the technician's group\" option from Escalade is enabled and takes " "precedence over this one." -#: templates/config.html.twig +#: templates/escalation_form.html.twig +msgid "Comment" +msgstr "Comment" + +#: templates/escalation_form.html.twig +msgid "Assign me as an observer" +msgstr "Assign me as an observer" + +#: templates/group_link.html.twig +msgid "Remove from the graph" +msgstr "Remove from the graph" + +#: templates/group_link.html.twig +#, php-format +msgid "Link type: %s" +msgstr "Link type: %s" + +#: templates/group_link.html.twig +msgid "The change could not be applied. Reload the page and try again." +msgstr "The change could not be applied. Reload the page and try again." + +#: src/Group_Link.php:69 src/Escalation.php:70 +msgid "Escalation" +msgid_plural "Escalations" +msgstr[0] "Escalation" +msgstr[1] "Escalations" + +#: src/LinkStrategy/NoneLink.php:49 +msgid "None" +msgstr "None" + +#: src/LinkStrategy/NoneLink.php:54 +msgid "Removes, from its entity, a link replicated from a parent entity" +msgstr "Removes, from its entity, a link replicated from a parent entity" + +#: src/LinkStrategy/BasicLink.php:44 +msgid "Basic" +msgstr "Basic" + +#: src/LinkStrategy/BasicLink.php:49 +msgid "Limited to the entity where it was created" +msgstr "Limited to the entity where it was created" + +#: src/LinkStrategy/InheritedLink.php:49 +msgid "Inherited" +msgstr "Inherited" + +#: src/LinkStrategy/InheritedLink.php:54 +msgid "Also replicated in the child entities" +msgstr "Also replicated in the child entities" + +#: src/Escalation.php:172 +#, php-format +msgid "Escalate from %1$s to %2$s" +msgstr "Escalate from %1$s to %2$s" + +#: src/Escalation.php:173 +#, php-format +msgid "Escalate to %s" +msgstr "Escalate to %s" + +#: src/Escalation.php:193 +msgid "Show / hide the comment" +msgstr "Show / hide the comment" + +#: src/Escalation.php:243 +msgid "This group is already assigned." +msgstr "This group is already assigned." + +#: src/Controller.php:83 +msgid "Controller" +msgstr "Controller" + +#: src/Controller.php:401 +msgid "The ticket you wish to close has tasks that need to be completed." +msgstr "The ticket you wish to close has tasks that need to be completed." + +#: src/Controller.php:516 +#, php-format +msgid "To close this %s, you must fill in the following fields:" +msgstr "To close this %s, you must fill in the following fields:" + +#: src/Controller.php:616 src/Controller.php:674 +msgid "To create this task, you must fill in the following fields:" +msgstr "To create this task, you must fill in the following fields:" + +#: src/EscalationTree/EscalationTree.php:231 +#, php-format +msgid "Hidden group #%d" +msgstr "Hidden group #%d" + +#: src/EscalationTree/TreeEditor.php:164 +msgid "" +"The active entity changed: the graph was reloaded, the unsaved changes were " +"discarded." +msgstr "" +"The active entity changed: the graph was reloaded, the unsaved changes were " +"discarded." + +#: src/EscalationTree/TreeEditor.php:180 +msgid "" +"The links were saved by someone else meanwhile: the graph was reloaded, the " +"unsaved changes were discarded." +msgstr "" +"The links were saved by someone else meanwhile: the graph was reloaded, the " +"unsaved changes were discarded." + +#: src/EscalationTree/TreeEditor.php:186 +msgid "Escalation links saved." +msgstr "Escalation links saved." + +#: src/EscalationTree/TreeEditor.php:233 +#, php-format +msgid "" +"The links were not saved: in the entity %1$s, they would make a loop between " +"the groups %2$s." +msgstr "" +"The links were not saved: in the entity %1$s, they would make a loop between " +"the groups %2$s." + +#: src/EscalationTree/TreeEditor.php:472 +#, php-format +msgid "" +"%1$s already escalates to %2$s, directly or not: linking them the other way " +"would create a loop." +msgstr "" +"%1$s already escalates to %2$s, directly or not: linking them the other way " +"would create a loop." + +#: src/EscalationTree/TreeEditor.php:478 +#, php-format +msgid "" +"%s cannot be linked in this entity: it is only shown for a link inherited " +"from a parent entity." +msgstr "" +"%s cannot be linked in this entity: it is only shown for a link inherited " +"from a parent entity." + +#: src/Config.php:58 src/Config.php:63 src/Config.php:89 +msgid "More options" +msgstr "More options" + +#: src/Config.php:178 +msgid "Default" +msgstr "Default" + +#: src/Config.php:358 +msgid "Actors and groups" +msgstr "Actors and groups" + +#: src/Config.php:361 msgid "Take the group of associated item" msgstr "Take the group of associated item" -#: templates/config.html.twig +#: src/Config.php:362 msgid "Take the requester group" msgstr "Take the requester group" -#: templates/config.html.twig +#: src/Config.php:363 msgid "Take the technician group" msgstr "Take the technician group" -#: templates/config.html.twig -msgid "Prevent closure with tasks in To Do status" -msgstr "Prevent closure with tasks in To Do status" - -#: templates/config.html.twig +#: src/Config.php:364 msgid "Assign technical manager when changing category" msgstr "Assign technical manager when changing category" -#: templates/config.html.twig +#: src/Config.php:365 msgid "Assign technical group when changing category" msgstr "Assign technical group when changing category" -#: templates/config.html.twig +#: src/Config.php:366 msgid "Assign technician from task to parent item" msgstr "Assign technician from task to parent item" -#: templates/config.html.twig +#: src/Config.php:370 +msgid "Closure" +msgstr "Closure" + +#: src/Config.php:373 +msgid "Prevent closure with tasks in To Do status" +msgstr "Prevent closure with tasks in To Do status" + +#: src/Config.php:377 msgid "Mandatory fields to Solve and Close ITILs" msgstr "Mandatory fields to Solve and Close ITILs" -#: templates/config.html.twig +#: src/Config.php:390 msgid "Mandatory fields for Tasks creation" msgstr "Mandatory fields for Tasks creation" -#: src/Config.php:54 src/Config.php:59 src/Config.php:86 -msgid "More options" -msgstr "More options" - -#: src/Config.php:191 -msgid "Default" -msgstr "Default" - -#: src/Controller.php:75 -msgid "Controller" -msgstr "Controller" - -#: src/Controller.php:340 -msgid "The ticket you wish to close has tasks that need to be completed." -msgstr "The ticket you wish to close has tasks that need to be completed." +#: src/Config.php:392 +msgid "These settings are shared by tickets, changes and problems." +msgstr "These settings are shared by tickets, changes and problems." -#: src/Controller.php:436 -#, php-format -msgid "To close this %s, you must fill in the following fields:" -msgstr "To close this %s, you must fill in the following fields:" - -#: src/Controller.php:473 -msgid "To create this task, you must fill in the following fields:" -msgstr "To create this task, you must fill in the following fields:" +#: src/Config.php:406 +msgid "Activate escalation" +msgstr "Activate escalation" diff --git a/locales/moreoptions.pot b/locales/moreoptions.pot index 45d4d77..789f19b 100644 --- a/locales/moreoptions.pot +++ b/locales/moreoptions.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-09-14 09:44+0000\n" +"POT-Creation-Date: 2026-10-02 10:20+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -16,6 +16,207 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" + +#: templates/escalation_button.html.twig templates/escalation_form.html.twig +#: src/Config.php:334 src/Config.php:403 +msgid "Escalate" +msgstr "" + +#: templates/components/segmented_control.html.twig +msgid "Follow the parent entity" +msgstr "" + +#: templates/components/group_link/legend.html.twig +msgid "Link to draw" +msgstr "" + +#: templates/components/group_link/legend.html.twig +#, php-format +msgid "%s, replicated from a parent entity" +msgstr "" + +#: templates/components/group_link/group_list.html.twig +msgid "Search a group" +msgstr "" + +#: templates/components/group_link/group_list.html.twig +msgid "Drag onto the graph, or click, to place this group" +msgstr "" + +#: templates/components/group_link/group_list.html.twig +msgid "No group matches the search." +msgstr "" + +#: templates/components/group_link/group_list.html.twig +msgid "All the groups are placed." +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "History" +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Orientation" +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Horizontal" +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Vertical" +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Fit the graph to the view" +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Remove all the links" +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "" +"Remove the links of this entity, and restore the ones inherited from the " +"parent entities" +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Remove all the links of the graph?" +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "" +"Remove all the links of this entity? The links inherited from the parent " +"entities are restored." +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Drag from the circle of a group onto another group to link them." +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Drag a group of the list onto the graph to place it." +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Right-click a group or a link for more actions." +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Delete: removes the selection. Ctrl+Z / Ctrl+Y: undo / redo." +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Drag the background to move around, scroll to zoom." +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "Dashed links are inherited from a parent entity." +msgstr "" + +#: templates/components/group_link/editor.html.twig +msgid "The links shown and saved are the ones of the active entity" +msgstr "" + +#: templates/components/group_link/editor.html.twig +#, php-format +msgid "%d group" +msgid_plural "%d groups" +msgstr[0] "" +msgstr[1] "" + +#: templates/components/group_link/editor.html.twig +#, php-format +msgid "%d link" +msgid_plural "%d links" +msgstr[0] "" +msgstr[1] "" + +#: templates/components/group_link/group_panel.html.twig +msgid "Level" +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +#: templates/group_link.html.twig +#, php-format +msgid "L%d" +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +msgid "Escalates to" +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +msgid "This group does not escalate to any group." +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +msgid "Add a group to escalate to" +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +msgid "Receives escalations from" +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +msgid "No group escalates to it: it is a first level." +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +msgid "Add a group escalating to it" +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +msgid "Remove the group from the graph, with its links" +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +#: templates/components/group_link/link_panel.html.twig +msgid "Delete key" +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +#, php-format +msgid "%1$s link replicated from the entity %2$s" +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +#: templates/components/group_link/link_panel.html.twig +#: templates/group_link.html.twig +msgid "Delete the link" +msgstr "" + +#: templates/components/group_link/group_panel.html.twig +#: templates/components/group_link/link_panel.html.twig +msgid "Link type" +msgstr "" + +#: templates/components/group_link/link_panel.html.twig +msgid "Link" +msgstr "" + +#: templates/components/group_link/link_panel.html.twig +msgid "Escalates to the child group" +msgstr "" + +#: templates/components/group_link/link_panel.html.twig +msgid "Receives the escalations of the parent group" +msgstr "" + +#: templates/components/group_link/link_panel.html.twig +msgid "Deleting it here removes it from this entity and its child entities." +msgstr "" + +#: templates/components/group_link/link_panel.html.twig +#, php-format +msgid "Inherited from %s" +msgstr "" + +#: templates/config.html.twig +msgid "Inherit" +msgstr "" #: templates/config.html.twig msgid "" @@ -23,63 +224,190 @@ msgid "" "precedence over this one." msgstr "" -#: templates/config.html.twig -msgid "Take the group of associated item" +#: templates/escalation_form.html.twig +msgid "Comment" msgstr "" -#: templates/config.html.twig -msgid "Take the requester group" +#: templates/escalation_form.html.twig +msgid "Assign me as an observer" msgstr "" -#: templates/config.html.twig -msgid "Take the technician group" +#: templates/group_link.html.twig +msgid "Remove from the graph" msgstr "" -#: templates/config.html.twig -msgid "Prevent closure with tasks in To Do status" +#: templates/group_link.html.twig +#, php-format +msgid "Link type: %s" msgstr "" -#: templates/config.html.twig -msgid "Assign technical manager when changing category" +#: templates/group_link.html.twig +msgid "The change could not be applied. Reload the page and try again." msgstr "" -#: templates/config.html.twig -msgid "Assign technical group when changing category" +#: src/Group_Link.php:69 src/Escalation.php:70 +msgid "Escalation" +msgid_plural "Escalations" +msgstr[0] "" +msgstr[1] "" + +#: src/LinkStrategy/NoneLink.php:49 +msgid "None" msgstr "" -#: templates/config.html.twig -msgid "Assign technician from task to parent item" +#: src/LinkStrategy/NoneLink.php:54 +msgid "Removes, from its entity, a link replicated from a parent entity" msgstr "" -#: templates/config.html.twig -msgid "Mandatory fields to Solve and Close ITILs" +#: src/LinkStrategy/BasicLink.php:44 +msgid "Basic" msgstr "" -#: templates/config.html.twig -msgid "Mandatory fields for Tasks creation" +#: src/LinkStrategy/BasicLink.php:49 +msgid "Limited to the entity where it was created" msgstr "" -#: src/Config.php:54 src/Config.php:59 src/Config.php:86 -msgid "More options" +#: src/LinkStrategy/InheritedLink.php:49 +msgid "Inherited" msgstr "" -#: src/Config.php:191 -msgid "Default" +#: src/LinkStrategy/InheritedLink.php:54 +msgid "Also replicated in the child entities" +msgstr "" + +#: src/Escalation.php:172 +#, php-format +msgid "Escalate from %1$s to %2$s" +msgstr "" + +#: src/Escalation.php:173 +#, php-format +msgid "Escalate to %s" +msgstr "" + +#: src/Escalation.php:193 +msgid "Show / hide the comment" msgstr "" -#: src/Controller.php:75 +#: src/Escalation.php:243 +msgid "This group is already assigned." +msgstr "" + +#: src/Controller.php:83 msgid "Controller" msgstr "" -#: src/Controller.php:340 +#: src/Controller.php:401 msgid "The ticket you wish to close has tasks that need to be completed." msgstr "" -#: src/Controller.php:436 +#: src/Controller.php:516 #, php-format msgid "To close this %s, you must fill in the following fields:" msgstr "" -#: src/Controller.php:473 +#: src/Controller.php:616 src/Controller.php:674 msgid "To create this task, you must fill in the following fields:" msgstr "" + +#: src/EscalationTree/EscalationTree.php:231 +#, php-format +msgid "Hidden group #%d" +msgstr "" + +#: src/EscalationTree/TreeEditor.php:164 +msgid "" +"The active entity changed: the graph was reloaded, the unsaved changes were " +"discarded." +msgstr "" + +#: src/EscalationTree/TreeEditor.php:180 +msgid "" +"The links were saved by someone else meanwhile: the graph was reloaded, the " +"unsaved changes were discarded." +msgstr "" + +#: src/EscalationTree/TreeEditor.php:186 +msgid "Escalation links saved." +msgstr "" + +#: src/EscalationTree/TreeEditor.php:233 +#, php-format +msgid "" +"The links were not saved: in the entity %1$s, they would make a loop between " +"the groups %2$s." +msgstr "" + +#: src/EscalationTree/TreeEditor.php:472 +#, php-format +msgid "" +"%1$s already escalates to %2$s, directly or not: linking them the other way " +"would create a loop." +msgstr "" + +#: src/EscalationTree/TreeEditor.php:478 +#, php-format +msgid "" +"%s cannot be linked in this entity: it is only shown for a link inherited " +"from a parent entity." +msgstr "" + +#: src/Config.php:58 src/Config.php:63 src/Config.php:89 +msgid "More options" +msgstr "" + +#: src/Config.php:178 +msgid "Default" +msgstr "" + +#: src/Config.php:358 +msgid "Actors and groups" +msgstr "" + +#: src/Config.php:361 +msgid "Take the group of associated item" +msgstr "" + +#: src/Config.php:362 +msgid "Take the requester group" +msgstr "" + +#: src/Config.php:363 +msgid "Take the technician group" +msgstr "" + +#: src/Config.php:364 +msgid "Assign technical manager when changing category" +msgstr "" + +#: src/Config.php:365 +msgid "Assign technical group when changing category" +msgstr "" + +#: src/Config.php:366 +msgid "Assign technician from task to parent item" +msgstr "" + +#: src/Config.php:370 +msgid "Closure" +msgstr "" + +#: src/Config.php:373 +msgid "Prevent closure with tasks in To Do status" +msgstr "" + +#: src/Config.php:377 +msgid "Mandatory fields to Solve and Close ITILs" +msgstr "" + +#: src/Config.php:390 +msgid "Mandatory fields for Tasks creation" +msgstr "" + +#: src/Config.php:392 +msgid "These settings are shared by tickets, changes and problems." +msgstr "" + +#: src/Config.php:406 +msgid "Activate escalation" +msgstr "" diff --git a/public/css/moreoptions.scss b/public/css/moreoptions.scss index 865bcb4..0ce2c88 100644 --- a/public/css/moreoptions.scss +++ b/public/css/moreoptions.scss @@ -85,7 +85,8 @@ // Same for the status dropdowns, whose "Inherit" option is Config::CONFIG_PARENT (-2). .form-field:has(.mo-seg-inherit:checked) .mo-inherited, - .form-field:has(.mo-status option[value="-2"]:checked) .mo-inherited { + .form-field:has(.mo-status option[value="-2"]:checked) .mo-inherited, + .form-field:has(.mo-profiles option[value="-2"]:checked) .mo-inherited { display: inline-flex; align-items: center; } @@ -290,3 +291,157 @@ text-align: center; } } + +// Escalation graph of the "Escalation" tab of a group (see group_link.html.twig). +.mo-group-link { + --mo-gl-accent: #3a5693; + // Height of the graph, and of the side panel next to it + --mo-gl-height: 640px; + // Arrows of the link strategies (see AbstractLinkStrategy::getColor()) + // Basic: neutral; inherited: the azure of the GLPI badge of the values inherited from a parent entity (see Entity::inheritedValue()). + --mo-gl-basic: var(--tblr-body-color); + --mo-gl-inherited: var(--tblr-azure, #4299e1); + + overflow: hidden; + + &.mo-gl-busy { + cursor: progress; + } + + [data-glpi-theme-dark="1"] & { + --mo-gl-accent: #8aa5dc; + } + + // Choices of the editor (strategy, orientation): the arrows stay readable on the chosen one. + // GLPI styles a checked button with these variables (and !important). + .btn-check:checked + .btn { + --tblr-btn-hover-color: var(--tblr-body-color); + --tblr-btn-active-bg: color-mix(in srgb, var(--mo-gl-accent), var(--tblr-bg-surface) 88%); + border-color: var(--mo-gl-accent); + } + + .mo-gl-body { + display: flex; + align-items: stretch; + } + + // Container of the Cytoscape.js graph (see public/js/escalation_graph.js). + .mo-gl-canvas { + position: relative; + flex: 1; + min-width: 0; + height: var(--mo-gl-height); + // A fixed grid: it does not follow the moves and the zoom of the graph, nothing to redraw. + background-color: var(--tblr-bg-surface-secondary); + background-image: + linear-gradient(to right, var(--tblr-border-color) 1px, transparent 1px), + linear-gradient(to bottom, var(--tblr-border-color) 1px, transparent 1px); + background-size: 24px 24px; + + // A group of the list is dragged over it. + &.mo-gl-drop-target { + outline: 2px dashed var(--mo-gl-accent); + outline-offset: -4px; + } + } + + // Link replicated from a parent entity, in the legend and in the panels: dashed as in the graph + // (see `line-dash-pattern` in public/js/escalation_graph.js). + .mo-gl-edge-replicated { + stroke-dasharray: 7 5; + } + + // Side panel, made of Tabler card parts (card-header, list-group, card-footer). + .mo-gl-side { + display: flex; + flex: 0 0 300px; + flex-direction: column; + height: var(--mo-gl-height); + border-left: 1px solid var(--tblr-border-color); + } + + // Scrolls when long, rather than pushing the group list out of the panel. + .mo-gl-selection { + flex: 0 1 auto; + min-height: 0; + overflow: auto; + border-bottom: 1px solid var(--tblr-border-color); + } + + .mo-gl-pool { + display: flex; + flex: 1; + flex-direction: column; + gap: 8px; + min-height: 0; + padding: 4px 16px 16px; + overflow: auto; + } + + // Folded section of the group list + .mo-gl-section-chevron { + transition: transform 0.15s; + } + + .collapsed .mo-gl-section-chevron { + transform: rotate(-90deg); + } + + // A group to place: a small card, as the groups of the graph, which can be dragged onto it. + .mo-gl-pool-item { + display: flex; + flex: none; + flex-direction: row; + align-items: center; + gap: 10px; + width: 100%; + margin: 0; + padding: 8px 10px 8px 6px; + color: inherit; + text-align: start; + cursor: grab; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s; + + &:hover, + &:focus-visible { + border-color: var(--mo-gl-accent); + box-shadow: 0 2px 6px rgba(16, 25, 35, 0.12); + transform: translateY(-1px); + } + + &:active { + cursor: grabbing; + } + + &.mo-gl-dragging { + opacity: 0.5; + border-style: dashed; + } + + .mo-gl-pool-item-grip { + color: var(--tblr-secondary); + opacity: 0.6; + } + + &:hover .mo-gl-pool-item-grip { + opacity: 1; + } + } + + // Narrow screens: the side panel goes below the graph. + @media (max-width: 767.98px) { + --mo-gl-height: 420px; + + .mo-gl-body { + flex-direction: column; + } + + .mo-gl-side { + flex-basis: auto; + height: auto; + max-height: var(--mo-gl-height); + border-top: 1px solid var(--tblr-border-color); + border-left: 0; + } + } +} diff --git a/public/js/escalation_graph.js b/public/js/escalation_graph.js new file mode 100644 index 0000000..fa3834a --- /dev/null +++ b/public/js/escalation_graph.js @@ -0,0 +1,911 @@ +/* + * ------------------------------------------------------------------------- + * MoreOptions 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 MoreOptions plugin team. + * @license MIT https://opensource.org/licenses/mit-license.php + * @link https://github.com/pluginsGLPI/moreoptions + * @link https://gitlab.teclib.com/glpi-network/moreoptions/ + * ------------------------------------------------------------------------- + */ + +window.GlpiPluginMoreoptionsEscalationGraph = class { + /** Temporary node following the pointer while a link is drawn */ + static TMP_NODE = 'mo-gl-tmp-node'; + + /** Pointer move after which a press on an output point draws a link */ + static DRAG_THRESHOLD = 5; + + /** Duration of the moves of the groups when the layout changes, in milliseconds. */ + static ANIMATION_DURATION = 300; + + /** Number of changes that can be undone. */ + static HISTORY_SIZE = 20; + + /** Key of the folded sections of the group list in the storage of the browser. */ + static FOLDED_KEY = 'moreoptions-escalation-folded'; + + /** Key of the orientation and the drawing strategy of the user in the storage of the browser. */ + static PREFERENCES_KEY = 'moreoptions-escalation-preferences'; + + /** + * Editors of the page: the ones of a reloaded tab are destroyed. Kept when this script runs + * again, with the tab. + */ + static instances = window.GlpiPluginMoreoptionsEscalationGraph?.instances ?? new Set(); + + /** + * @param {HTMLElement} container Wrapper of the editor, with its configuration in `data-mo-config` + */ + constructor(container) { + this.constructor.instances.forEach(editor => !editor.container.isConnected && editor.destroy()); + this.constructor.instances.add(this); + this.container = container; + + // Listeners on the page, removed with the editor + this.listeners = new AbortController(); + this.config = JSON.parse(container.dataset.moConfig); + this.orientation = null; + + // Changes are sent one at a time, in order: each one with the draft of the previous response. + this.queue = []; + this.pending = false; + // Resolves the promise of save(), once the draft is saved or refused + this.saved = null; + this.colors = new Map(); + this.badges = new Map(); + this.segments = new WeakMap(); + // Link being drawn (see enableLinkDrawing()): the graph is only updated once it is drawn. + this.drawing = null; + this.outdated = false; + // Sections of the group list folded by the user (see group_list.html.twig) + this.folded = new Set(this.readStorage(this.constructor.FOLDED_KEY) ?? []); + // Drafts before the changes made (undo), and before the changes undone (redo) + this.history = { undo: [], redo: [] }; + + this.foldSections(this.form); + this.createGraph(); + this.updateGraph(); + this.updateHistoryButtons(); + // The first render shows the whole graph; the view is then the one of the user. + this.fit(); + this.bindEditorEvents(); + this.bindKeyboard(); + this.bindGroupDragAndDrop(); + this.restorePreferences(); + } + + get form() { + return this.container.querySelector('[data-mo-graph]'); + } + + get canEdit() { + return this.form?.hasAttribute('data-mo-canedit') ?? false; + } + + get hasSelection() { + return this.form.hasAttribute('data-mo-has-selection'); + } + + /** The draft held by the editor (see TreeEditor::getState()), as sent back with each change */ + get state() { + return this.form.elements.state.value; + } + + /** Duration and easing of the animations of the graph */ + get animationOptions() { + return { duration: this.constructor.ANIMATION_DURATION, easing: 'ease-in-out' }; + } + + get reducedMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; + } + + /** Stops the editor of a tab gone: its graph, its context menu and its listeners on the page. */ + destroy() { + this.constructor.instances.delete(this); + this.listeners.abort(); + this.queue = []; + this.request?.abort(); + if (this.cy !== undefined && !this.cy.destroyed()) { + this.cy.contextMenus('get').destroy(); + this.cy.destroy(); + } + } + + // ---------------------------------------------------------------------------------------- + // Graph + // ---------------------------------------------------------------------------------------- + + /** Creates the graph, once: its elements are then updated by updateGraph(). */ + createGraph() { + this.cy = cytoscape({ + container: this.container.querySelector('[data-mo-cy]'), + layout: { name: 'preset' }, + wheelSensitivity: 0.25, + minZoom: 0.5, + maxZoom: 2, + // Positions are computed server side, and the selection is the one of the server. + autoungrabify: true, + autounselectify: true, + boxSelectionEnabled: false, + }); + this.cy.on('tap', 'node', (event) => { + if (event.target.id() !== this.constructor.TMP_NODE) { + this.send('select_node', { group: event.target.id() }); + } + }); + this.cy.on('tap', 'edge', event => this.send('select_link', { link: event.target.id() })); + this.cy.on('tap', (event) => { + if (event.target === this.cy && this.hasSelection) { + this.send('clear'); + } + }); + this.cy.on('mouseover', 'node, edge', () => { this.cy.container().style.cursor = 'pointer'; }); + this.cy.on('mouseout', 'node, edge', () => { this.cy.container().style.cursor = ''; }); + + if (this.canEdit) { + this.enableLinkDrawing(); + this.enableContextMenu(); + } + } + + /** + * Applies the elements of the editor to the graph. Cytoscape compares them by id: elements gone + * are removed, new ones added, the others updated (data, position, classes such as the selection). + */ + updateGraph() { + this.outdated = false; + this.cy.elements().stop(true, true); + + // Get the elements of the graph to get data + const form = this.form; + const elements = JSON.parse(this.container.querySelector('[data-mo-elements]').textContent); + elements.edges.forEach((edge) => { edge.data.color = this.resolveColor(edge.data.color); }); + + // Animation of the groups moving to their new position + const previousPositions = new Map(this.cy.nodes().map(node => [node.id(), { ...node.position() }])); + this.cy.json({ elements }); + if (previousPositions.size > 0) { + this.animateChanges(previousPositions); + } + + // Set the style of the graph, depending on the orientation + if (form.dataset.moOrientation !== this.orientation) { + this.orientation = form.dataset.moOrientation; + this.cy.style(this.getStyle(this.orientation === 'vertical')); + } + + // Center the graph on the element that was added + const added = this.cy.getElementById(form.dataset.moScrollTo ?? ''); + if (added.nonempty()) { + const box = added.boundingBox(); + const view = this.cy.extent(); + if (box.x1 < view.x1 || box.x2 > view.x2 || box.y1 < view.y1 || box.y2 > view.y2) { + this.cy.animate({ center: { eles: added } }, { duration: this.reducedMotion ? 0 : this.constructor.ANIMATION_DURATION }); + } + } + } + + /** + * The points where a link turns, as Cytoscape wants them for a "segments" edge: their position + * along the line from the start of the link to its end (weight), and their distance from it. + */ + getSegments(edge) { + const route = edge.data('route'); + if (!this.segments.has(route)) { + const { from, to, points } = route; + const [dx, dy] = [to.x - from.x, to.y - from.y]; + const length = Math.hypot(dx, dy) || 1; + // A straight link has no segment: neutral values, Cytoscape refusing empty ones + this.segments.set(route, { + weights: points.map(({ x, y }) => ((x - from.x) * dx + (y - from.y) * dy) / (length * length)).join(' ') || '0.5', + distances: points.map(({ x, y }) => ((y - from.y) * dx - (x - from.x) * dy) / length).join(' ') || '0', + }); + } + return this.segments.get(route); + } + + /** + * The groups moved by the last change glide from their previous position to their new one, and + * the new ones fade in, unless the user prefers reduced motion. + * + * @param {Map} previousPositions Positions of the nodes before the change, by id + */ + animateChanges(previousPositions) { + if (this.reducedMotion) { + return; + } + + const groups = this.cy.nodes(); + const moved = groups.filter((group) => { + const previous = previousPositions.get(group.id()); + return previous !== undefined && (previous.x !== group.position('x') || previous.y !== group.position('y')); + }); + + moved.forEach((group) => { + const position = { ...group.position() }; + group.position(previousPositions.get(group.id())).animate({ position }, this.animationOptions); + }); + this.fadeIn(groups.filter(group => !previousPositions.has(group.id()))); + // The routes of the links are the ones of the new layout: shown once the groups are in place. + if (moved.nonempty()) { + this.fadeIn(this.cy.edges(), this.constructor.ANIMATION_DURATION); + } + } + + /** + * Makes elements of the graph appear: one animation per element, each one clearing its own + * opacity once done. + * + * @param {Object} elements Collection of Cytoscape elements + * @param {number} delay Milliseconds before they appear + */ + fadeIn(elements, delay = 0) { + elements.style('opacity', 0).forEach(element => element.delay(delay).animate( + { style: { opacity: 1 } }, + { ...this.animationOptions, complete: () => element.removeStyle('opacity') }, + )); + } + + /** + * Badge of the level of a group ("L2"), drawn on it as a background image: it follows the group, + * the zoom and the animations. + */ + getLevelBadge(level) { + if (!this.badges.has(level)) { + const text = this.config.strings.level.replace('%d', level); + const width = 10 + text.length * 6; + const [fill, color] = [this.resolveColor('color-mix(in srgb, var(--mo-gl-accent), var(--tblr-bg-surface) 85%)'), this.resolveColor('var(--mo-gl-accent)')]; + const svg = `` + + `` + + `${text}` + + ''; + this.badges.set(level, { image: 'data:image/svg+xml;utf8,' + encodeURIComponent(svg), width }); + } + return this.badges.get(level); + } + + /** Whole graph in view, without zooming in beyond 100%. */ + fit() { + this.cy.fit(undefined, 30); + if (this.cy.zoom() > 1) { + this.cy.zoom(1); + this.cy.center(); + } + } + + /** Cytoscape needs actual colors: CSS colors (variables included) are resolved in the page. */ + resolveColor(value) { + if (!this.colors.has(value)) { + const probe = document.createElement('span'); + probe.style.color = value; + this.container.append(probe); + this.colors.set(value, getComputedStyle(probe).color); + probe.remove(); + } + return this.colors.get(value); + } + + getStyle(vertical) { + const [text, surface, border, accent, danger] = ['--tblr-body-color', '--tblr-bg-surface', '--tblr-border-color', '--mo-gl-accent', '--tblr-danger'] + .map(name => this.resolveColor(`var(${name})`)); + const { width, height } = this.config.node; + const highlight = { 'border-width': 2, 'border-color': accent }; + + return [ + // Stacking: links (0) under the groups (1), under the link being drawn (20) + { selector: 'node, edge', style: { 'z-index-compare': 'manual', 'z-index': 0 } }, + { + selector: 'node[label]', + style: { + 'z-index': 1, 'shape': 'round-rectangle', 'width': width, 'height': height, + 'background-color': surface, 'border-width': 1, 'border-color': border, + 'label': 'data(label)', 'color': text, 'font-family': getComputedStyle(this.container).fontFamily, + 'font-size': 13, 'font-weight': 600, 'text-valign': 'center', 'text-halign': 'center', + 'text-wrap': 'wrap', 'text-max-width': width - 14, + // Badge of the level, at the top left + 'background-image': node => this.getLevelBadge(node.data('level')).image, + 'background-width': node => this.getLevelBadge(node.data('level')).width, + 'background-height': 16, + 'background-fit': 'none', + 'background-position-x': 6, + 'background-position-y': 6, + 'background-clip': 'none', + }, + }, + // Light feedback while pressed (Cytoscape draws a large gray overlay by default) + { selector: 'node:active, edge:active', style: { 'overlay-color': accent, 'overlay-opacity': 0.08, 'overlay-padding': 4 } }, + { selector: 'node.source', style: highlight }, + { selector: 'node.selected, node.drop', style: { ...highlight, 'underlay-color': accent, 'underlay-opacity': 0.15, 'underlay-padding': 4 } }, + // Ancestor of the group a link is drawn from: linking to it would make a loop. + { selector: 'node.forbidden', style: { 'border-color': danger, 'border-style': 'dashed', 'opacity': 0.6 } }, + // Link being drawn: under the pointer, but not to be hovered or tapped + { selector: '#' + this.constructor.TMP_NODE, style: { 'width': 1, 'height': 1, 'opacity': 0, 'events': 'no' } }, + { + selector: 'edge', + style: { + 'width': 2.5, 'line-color': 'data(color)', 'target-arrow-color': 'data(color)', 'target-arrow-shape': 'triangle', 'arrow-scale': 1.2, + // From the output side of the source group to the input side of the target one + 'source-endpoint': vertical ? '0% 50%' : '50% 0%', 'target-endpoint': vertical ? '0% -50%' : '-50% 0%', + 'source-distance-from-node': 0, 'target-distance-from-node': 1, + }, + }, + // Along the route computed server side, never crossing a group, from its start to its end + { + selector: 'edge[route]', + style: { + 'curve-style': edge => (edge.data('route').points.length > 0 ? 'segments' : 'straight'), + 'edge-distances': 'endpoints', + 'segment-weights': edge => this.getSegments(edge).weights, + 'segment-distances': edge => this.getSegments(edge).distances, + 'source-endpoint': edge => edge.data('endpoints').source, + 'target-endpoint': edge => edge.data('endpoints').target, + }, + }, + { selector: 'edge[?replicated]', style: { 'line-style': 'dashed', 'line-dash-pattern': [7, 5] } }, + { selector: 'edge.selected', style: { 'width': 4.5 } }, + { + selector: 'edge.preview', + style: { + 'z-index': 20, 'events': 'no', 'curve-style': 'straight', 'line-style': 'dashed', + 'source-endpoint': 'outside-to-node', 'target-endpoint': 'outside-to-node', 'source-distance-from-node': 0, 'target-distance-from-node': 0, + }, + }, + { selector: 'edge.preview.forbidden', style: { 'line-color': danger, 'target-arrow-color': danger } }, + ]; + } + + /** + * Dragging a group onto another group links them. A press without moving stays a tap + * (selection of the group). A group cannot escalate to one of its ancestors: that would make a + * loop (checked again server side). + */ + enableLinkDrawing() { + const TMP_NODE = this.constructor.TMP_NODE; + const GROUPS = 'node[label]'; + // The group a link would go to: a group cannot escalate to one of its ancestors. + const setTarget = (target) => { + const forbidden = target !== null && this.drawing.group.predecessors().contains(target); + this.drawing.target?.removeClass('drop forbidden'); + target?.addClass(forbidden ? 'forbidden' : 'drop'); + this.cy.getElementById(TMP_NODE + '-edge').toggleClass('forbidden', forbidden); + this.drawing.target = target; + }; + + this.cy.on('tapstart', GROUPS, (event) => { + this.drawing = { group: event.target, start: { ...event.renderedPosition }, started: false, target: null }; + this.cy.userPanningEnabled(false); + }); + + this.cy.on('tapdrag', (event) => { + if (this.drawing === null) { + return; + } + if (!this.drawing.started) { + const { x, y } = event.renderedPosition; + if (Math.hypot(x - this.drawing.start.x, y - this.drawing.start.y) < this.constructor.DRAG_THRESHOLD) { + return; + } + this.drawing.started = true; + this.drawing.group.addClass('source'); + this.cy.container().style.cursor = 'crosshair'; + // Drawn with the arrow of the strategy chosen above the graph + const strategy = this.config.strategies.find(({ value }) => value === this.form.dataset.moDrawingStrategy); + this.cy.add([ + { group: 'nodes', data: { id: TMP_NODE }, position: { ...event.position } }, + { + group: 'edges', + data: { id: TMP_NODE + '-edge', source: this.drawing.group.id(), target: TMP_NODE, color: this.resolveColor(strategy?.color ?? 'var(--mo-gl-accent)') }, + classes: 'preview', + }, + ]); + } + this.cy.getElementById(TMP_NODE).position({ ...event.position }); + }); + + // The group hovered while drawing + this.cy.on('tapdragover', GROUPS, (event) => { + if (this.drawing?.started && event.target !== this.drawing.group) { + setTarget(event.target); + } + }); + this.cy.on('tapdragout', GROUPS, (event) => { + if (this.drawing?.started && event.target === this.drawing.target) { + setTarget(null); + } + }); + + this.cy.on('tapend', () => { + if (this.drawing === null) { + return; + } + const { group, started, target } = this.drawing; + this.drawing = null; + this.cy.userPanningEnabled(true); + if (started) { + this.cy.getElementById(TMP_NODE).remove(); + this.cy.container().style.cursor = ''; + group.removeClass('source'); + target?.removeClass('drop forbidden'); + // A response came while drawing + if (this.outdated) { + this.updateGraph(); + } + // A forbidden target is sent anyway: the server refuses it, and explains why. + if (target !== null) { + this.send('link', { from: group.id(), to: target.id() }); + } + } + }); + } + + /** Context menu (right click), as in the impact analysis. */ + enableContextMenu() { + const { strings, strategies } = this.config; + const item = (id, content, selector, action, params = {}) => ({ + id: 'mo-gl-' + id, + content, + selector, + onClickFunction: event => this.send(action, { [selector.startsWith('node') ? 'group' : 'link']: event.target.id(), ...params }), + }); + + this.cy.contextMenus({ + menuItems: [ + item('remove-node', strings.remove_node, 'node[label]', 'remove_node'), + // A replicated link cannot be changed, only removed from the entity. + ...strategies.map(strategy => item( + 'type-' + strategy.value, + strings.link_type.replace('%s', strategy.label), + `edge[!replicated][type != "${strategy.value}"]`, + 'set_link_type', + { value: strategy.value }, + )), + item('delete-link', strings.delete_link, 'edge', 'delete_link'), + ], + }); + // Buttons in the form of the editor: not to submit it (Enter in a field would click them). + this.cy.container().querySelectorAll('.cy-context-menus-cxt-menuitem').forEach((button) => { button.type = 'button'; }); + } + + // ---------------------------------------------------------------------------------------- + // Editor + // ---------------------------------------------------------------------------------------- + + /** + * Sends a change, along with the draft, and renders the editor of the response. A change made + * while another one is being sent waits for it. Undoing (`undo`) sends back the draft before the + * last change, redoing (`redo`) the one before the last change undone. + * + * @param {string} action + * @param {Object} params + * @param {'change'|'undo'|'redo'} kind + */ + send(action, params = {}, kind = 'change') { + // The fields as they are now: the editor may be rendered again before the change is sent. + const fields = Object.fromEntries(new FormData(this.form)); + delete fields.state; + this.queue.push({ action, params: { ...fields, ...params }, kind }); + if (!this.pending) { + this.sendNext(); + } + } + + sendNext() { + const next = this.queue.shift(); + this.pending = next !== undefined; + this.container.classList.toggle('mo-gl-busy', this.pending); + if (next === undefined) { + return; + } + const before = this.state; + // The draft to go back to, when undoing or redoing + const restored = next.kind === 'change' ? undefined : this.history[next.kind].pop(); + if (next.kind !== 'change' && restored === undefined) { + this.sendNext(); + return; + } + + this.request = $.post(this.config.url, { ...next.params, state: restored ?? before, action: next.action }); + this.request.done((html) => { + if (this.cy.destroyed()) { + return; + } + this.replaceEditor(html); + this.outdated = this.drawing?.started ?? false; + if (!this.outdated) { + this.updateGraph(); + } + this.recordHistory(next, before); + this.writeStorage(this.constructor.PREFERENCES_KEY, this.preferences); + if (next.action === 'save') { + displayAjaxMessageAfterRedirect(); + // Refused (a loop, links saved by someone else meanwhile): the error is shown. + this.resolveSave(this.form.querySelector('[data-mo-error]') === null); + } + }).fail(() => { + if (this.cy.destroyed()) { + return; + } + // The changes waiting were made on the draft the server refused, the save among them. + this.queue = []; + this.resolveSave(false); + if (restored !== undefined) { + this.history[next.kind].push(restored); + } + glpi_toast_error(this.config.strings.error); + }).always(() => { + this.updateHistoryButtons(); + this.sendNext(); + }); + } + + /** + * Saves the draft, after the changes being sent: the "Save" button of the configuration saves + * the graph with the settings (see config.html.twig). + * + * @returns {Promise} Whether it was saved + */ + save() { + this.resolveSave(false); + return new Promise((resolve) => { + this.saved = resolve; + this.send('save'); + }); + } + + /** + * @param {boolean} saved + */ + resolveSave(saved) { + const resolve = this.saved; + this.saved = null; + resolve?.(saved); + } + + // ---------------------------------------------------------------------------------------- + // History + // ---------------------------------------------------------------------------------------- + + /** + * The part of a draft the history follows: the tree (groups, links, removed links, reset), not + * the selection. + */ + treeOf({ nodes, links, removed, reset }) { + return JSON.stringify({ nodes, links, removed, reset }); + } + + /** + * Keeps the draft before a change, to undo it. A change undone can be redone, until another + * change is made. Saving, or reloading the graph, starts a new history. + * + * @param {{action: string, kind: string}} change + * @param {string} before Draft before the change + */ + recordHistory(change, before) { + const { undo, redo } = this.history; + const [previous, current] = [JSON.parse(before), JSON.parse(this.state)]; + // Saved, or reloaded (another entity, links saved by someone else): a new history. + if (previous.entities_id !== current.entities_id || previous.version !== current.version) { + this.history = { undo: [], redo: [] }; + } else if (change.kind === 'undo') { + redo.push(before); + } else if (change.kind === 'redo') { + undo.push(before); + } else if (this.treeOf(previous) !== this.treeOf(current)) { + undo.push(before); + undo.splice(0, undo.length - this.constructor.HISTORY_SIZE); + redo.length = 0; + } + } + + undo() { + this.send('render', {}, 'undo'); + } + + redo() { + this.send('render', {}, 'redo'); + } + + updateHistoryButtons() { + this.container.querySelector('[data-mo-undo]')?.toggleAttribute('disabled', this.history.undo.length === 0); + this.container.querySelector('[data-mo-redo]')?.toggleAttribute('disabled', this.history.redo.length === 0); + } + + /** + * Replaces the editor by the one of the response, around the container of the graph, which + * stays in the page. Inserted with jQuery, so that the scripts of the GLPI dropdowns run. The + * focus, and the search typed meanwhile, are kept. When the element focused is gone, the focus + * goes to the panel of the selection, or back to the group selected in the list of the groups. + */ + replaceEditor(html) { + // Parsed out of the page: its scripts only run once inserted. + const freshForm = $('
').append($.parseHTML(html, document, true)).find('[data-mo-graph]')[0]; + this.foldSections(freshForm); + const liveForm = this.form; + const focused = this.selectorOf(document.activeElement); + const selected = JSON.parse(this.state).selected_node; + const search = liveForm.querySelector('[data-mo-search]'); + const typed = search === null ? null : { value: search.value, start: search.selectionStart, end: search.selectionEnd }; + // Shown tooltips and popovers would stay on the page once their element is replaced: hidden + // (disposed while fading out, Bootstrap would fail at the end of the fade). + liveForm.querySelectorAll('[data-bs-toggle="tooltip"], [data-bs-toggle="popover"]').forEach((element) => { + bootstrap.Tooltip.getInstance(element)?.hide(); + bootstrap.Popover.getInstance(element)?.hide(); + }); + [...liveForm.attributes].forEach(attribute => liveForm.removeAttribute(attribute.name)); + [...freshForm.attributes].forEach(attribute => liveForm.setAttribute(attribute.name, attribute.value)); + + // The parts of the form around its body, then the parts of the body around the graph + const replaceAround = (live, fresh, selector) => { + const keep = live.querySelector(selector); + const pivot = fresh.querySelector(selector); + const children = [...fresh.childNodes]; + [...live.childNodes].filter(node => node !== keep).forEach(node => node.remove()); + $(keep).before(children.slice(0, children.indexOf(pivot))).after(children.slice(children.indexOf(pivot) + 1)); + return [keep, pivot]; + }; + const [liveBody, freshBody] = replaceAround(liveForm, freshForm, ':scope > .mo-gl-body'); + replaceAround(liveBody, freshBody, ':scope > [data-mo-cy]'); + + const freshSearch = liveForm.querySelector('[data-mo-search]'); + if (typed !== null && freshSearch !== null && freshSearch.value !== typed.value) { + freshSearch.value = typed.value; + freshSearch.dispatchEvent(new Event('input', { bubbles: true })); + } + const element = focused === null ? null : liveForm.querySelector(focused) + ?? liveForm.querySelector('[data-mo-panel-title]') + ?? liveForm.querySelector(`[data-mo-list] [data-mo-group="${selected}"]`); + element?.focus({ preventScroll: true }); + if (element !== null && element === freshSearch && typed !== null) { + freshSearch.setSelectionRange(typed.start, typed.end); + } + } + + /** + * A selector of an element of the editor, finding it again once the editor is replaced. + * + * @returns {string|null} + */ + selectorOf(element) { + if (!(element instanceof HTMLElement) || !this.form.contains(element)) { + return null; + } + if (element.id !== '') { + return '#' + CSS.escape(element.id); + } + const attributes = ['name', 'data-mo-action', 'data-mo-group', 'data-mo-link', 'data-mo-direction', 'data-mo-search', 'data-mo-undo', 'data-mo-redo', 'data-mo-fit', 'data-mo-panel-title'] + .filter(attribute => element.hasAttribute(attribute)) + .map(attribute => `[${attribute}="${CSS.escape(element.getAttribute(attribute))}"]`); + + return attributes.length > 0 ? element.localName + attributes.join('') : null; + } + + /** Folds the sections of the group list the user folded. */ + foldSections(form) { + form.querySelectorAll('[data-mo-section]').forEach((section) => { + const folded = this.folded.has(section.dataset.moSection); + section.classList.toggle('show', !folded); + const toggle = form.querySelector(`[data-bs-target="#${CSS.escape(section.id)}"]`); + toggle?.classList.toggle('collapsed', folded); + toggle?.setAttribute('aria-expanded', String(!folded)); + }); + } + + /** A value kept in the browser, null when none or when the browser keeps nothing. */ + readStorage(key) { + try { + return JSON.parse(window.localStorage.getItem(key)); + } catch { + return null; + } + } + + writeStorage(key, value) { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + // Not kept: the sections are unfolded on the next page. + } + } + + /** + * The preferences of the user, sent with each change by the fields of the toolbar (see + * TreeEditor::setPreferences()): the tab is rendered with the default ones, so it is rendered + * again with the ones kept in the browser, if they differ. + */ + restorePreferences() { + const stored = this.readStorage(this.constructor.PREFERENCES_KEY); + const current = this.preferences; + if (stored !== null && (stored._orientation !== current._orientation || stored._drawing_strategy !== current._drawing_strategy)) { + this.send('render', { ...current, ...stored }); + } + } + + /** The preferences of the user the editor is rendered with. */ + get preferences() { + return { _orientation: this.form.dataset.moOrientation, _drawing_strategy: this.form.dataset.moDrawingStrategy }; + } + + /** Buttons, fields and search of the editor. */ + bindEditorEvents() { + // Sections of the group list folded or unfolded + ['shown.bs.collapse', 'hidden.bs.collapse'].forEach(type => this.container.addEventListener(type, (event) => { + const key = event.target.dataset.moSection; + if (key !== undefined) { + if (type === 'hidden.bs.collapse') { + this.folded.add(key); + } else { + this.folded.delete(key); + } + this.writeStorage(this.constructor.FOLDED_KEY, [...this.folded]); + } + })); + + this.container.addEventListener('click', (event) => { + if (event.target.closest('[data-mo-fit]') !== null) { + this.fit(); + return; + } + if (event.target.closest('[data-mo-undo]') !== null) { + this.undo(); + return; + } + if (event.target.closest('[data-mo-redo]') !== null) { + this.redo(); + return; + } + const trigger = event.target.closest('[data-mo-action]'); + // Fields send their change on "change"; a change with a confirmation is only sent once confirmed. + if (trigger !== null && !trigger.matches('input')) { + const { moAction, moGroup = '', moLink = '', moDirection = '', moConfirm } = trigger.dataset; + const send = () => this.send(moAction, { group: moGroup, link: moLink, direction: moDirection }); + if (moConfirm === undefined) { + send(); + } else { + // The message of the dialog is HTML. + const message = document.createElement('p'); + message.textContent = moConfirm; + glpi_confirm({ title: trigger.getAttribute('aria-label') ?? undefined, message: message.outerHTML, confirm_callback: send }); + } + } + }); + + this.container.addEventListener('change', (event) => { + if (event.target.matches('input[data-mo-action]')) { + this.send(event.target.dataset.moAction, { value: event.target.value }); + } + }); + + // Filtering the groups while typing does not need the server. + this.container.addEventListener('input', (event) => { + if (!event.target.matches('[data-mo-search]')) { + return; + } + const query = event.target.value.trim().toLowerCase(); + this.container.querySelectorAll('[data-mo-list]').forEach((list) => { + const items = [...list.querySelectorAll('[data-mo-name]')]; + items.forEach((item) => { item.hidden = !item.dataset.moName.includes(query); }); + list.querySelector('[data-mo-list-empty]').hidden = items.some(item => !item.hidden); + }); + }); + + this.container.addEventListener('submit', event => event.preventDefault()); + } + + /** + * Keyboard shortcuts of the editor, when the focus is in it (or nowhere), unless a field is + * being typed in: + * - Escape closes the panel of the selected group or link; + * - Ctrl+Z (Cmd+Z) undoes, Ctrl+Y or Ctrl+Shift+Z (Cmd+Shift+Z) redoes; + * - the Delete key (Backspace on Mac keyboards) removes the selected group or link: the one + * of the draft it is applied to, as the changes are sent one at a time. + */ + bindKeyboard() { + document.addEventListener('keydown', (event) => { + // The tab was reloaded: this editor is gone. + if (!this.container.isConnected) { + this.destroy(); + return; + } + const focus = document.activeElement; + if ( + event.defaultPrevented + || event.isComposing + || this.container.offsetParent === null + || (focus !== null && focus !== document.body && !this.container.contains(focus)) + || event.target.closest('input, textarea, select, [contenteditable], .select2-container') !== null + ) { + return; + } + if (event.key === 'Escape' && this.hasSelection) { + event.preventDefault(); + this.send('clear'); + return; + } + if (!this.canEdit) { + return; + } + const key = event.key.toLowerCase(); + if ((event.ctrlKey || event.metaKey) && !event.altKey && ['z', 'y'].includes(key)) { + event.preventDefault(); + if (key === 'y' || event.shiftKey) { + this.redo(); + } else { + this.undo(); + } + return; + } + if (['Delete', 'Backspace'].includes(event.key) && (this.pending || this.hasSelection)) { + event.preventDefault(); + this.send('delete_selection'); + } + }, { signal: this.listeners.signal }); + } + + /** Dragging a group of the list onto the graph (`data-mo-drop-zone`) places it. */ + bindGroupDragAndDrop() { + let dragged = null; + // The drop zone under the pointer, while a group of the list is dragged + const zoneOf = event => (dragged !== null ? event.target.closest('[data-mo-drop-zone]') : null); + const end = () => { + this.container.querySelectorAll('.mo-gl-dragging, .mo-gl-drop-target') + .forEach(element => element.classList.remove('mo-gl-dragging', 'mo-gl-drop-target')); + dragged = null; + }; + + this.container.addEventListener('dragstart', (event) => { + const item = event.target.closest('.mo-gl-pool-item'); + if (item !== null) { + dragged = item.dataset.moGroup; + item.classList.add('mo-gl-dragging'); + event.dataTransfer.effectAllowed = 'copy'; + event.dataTransfer.setData('text/plain', item.textContent.trim()); + } + }); + this.container.addEventListener('dragover', (event) => { + const zone = zoneOf(event); + if (zone !== null) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; + zone.classList.add('mo-gl-drop-target'); + } + }); + this.container.addEventListener('dragleave', (event) => { + const zone = zoneOf(event); + if (zone !== null && !zone.contains(event.relatedTarget)) { + zone.classList.remove('mo-gl-drop-target'); + } + }); + this.container.addEventListener('drop', (event) => { + if (zoneOf(event) !== null) { + event.preventDefault(); + const group = dragged; + end(); + this.send('add_node', { group }); + } + }); + this.container.addEventListener('dragend', end); + } +}; diff --git a/setup.php b/setup.php index 903b8c6..47b707d 100644 --- a/setup.php +++ b/setup.php @@ -69,7 +69,9 @@ function plugin_init_moreoptions(): void Plugin::registerClass(Config::class, ['addtabon' => 'Entity']); - Plugin::registerClass(Group_Link::class, ['addtabon' => 'Group']); + $PLUGIN_HOOKS[Hooks::ITEM_PURGE]['moreoptions'][Group::class] = Group_Link::cleanForGroup(...); + + $PLUGIN_HOOKS[Hooks::ITEM_PURGE]['moreoptions'][Entity::class] = Group_Link::cleanForEntity(...); $PLUGIN_HOOKS[Hooks::ADD_CSS]['moreoptions'][] = 'css/moreoptions.scss'; diff --git a/src/Config.php b/src/Config.php index 660021f..371de39 100644 --- a/src/Config.php +++ b/src/Config.php @@ -43,12 +43,17 @@ use CommonITILObject; use Entity; use Glpi\Application\View\TemplateRenderer; +use JsonException; use Migration; use Plugin; use Problem; +use Profile; use Session; use Ticket; +use function Safe\json_decode; +use function Safe\json_encode; + class Config extends CommonDBTM { public $dohistory = true; @@ -121,9 +126,50 @@ public static function preItemUpdate(CommonDBTM $item): CommonDBTM } } + foreach (self::getProfilesConfigFields() as $field) { + if (isset($item->input[$field])) { + $item->input[$field] = self::encodeProfiles($item->input[$field]); + } + } + return $item; } + /** + * Value stored for a `profiles` field, from the multiple select of the form: the ids of the + * profiles as a JSON list, or Config::CONFIG_PARENT when "Inherit" is among them. + */ + private static function encodeProfiles(mixed $value): string + { + $ids = array_map(intval(...), array_filter((array) $value, is_numeric(...))); + if (in_array(self::CONFIG_PARENT, $ids, true)) { + return (string) self::CONFIG_PARENT; + } + + return json_encode(array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0)))); + } + + /** + * Ids of the profiles of a `profiles` field, as stored by encodeProfiles(): none for an + * inherited value not resolved (see getConfig()) or an invalid one. + * + * @return list + */ + public static function decodeProfiles(mixed $value): array + { + if (!is_string($value) || $value === '' || (int) $value === self::CONFIG_PARENT) { + return []; + } + + try { + $ids = json_decode($value, true); + } catch (JsonException) { + return []; + } + + return is_array($ids) ? array_values(array_map(intval(...), array_filter($ids, is_numeric(...)))) : []; + } + /** * @return array */ @@ -141,6 +187,29 @@ private static function getActorGroupConfigFields(): array } /** + * Fields of kind `profiles`: lists of profiles, stored as JSON (see encodeProfiles()). + * + * @return array + */ + private static function getProfilesConfigFields(): array + { + return self::getConfigFieldsByKind('profiles'); + } + + /** + * Every field a child entity can inherit from its parent entity: the integer ones (see + * getAllConfigFields()) and the `profiles` ones. + * + * @return array + */ + private static function getInheritableConfigFields(): array + { + return array_merge(self::getAllConfigFields(), self::getProfilesConfigFields()); + } + + /** + * Integer fields: the `yes_no`, `actor` and `status` ones. + * * @return array */ private static function getAllConfigFields(): array @@ -349,6 +418,9 @@ public static function showForEntity(Entity $item): void 'status_options' => $status_options, 'config_parent' => self::CONFIG_PARENT, 'escalade_takes_technician_group' => self::isTechnicianGroupHandledByEscalade(), + 'profile_options' => ($item->getID() > 0 ? [self::CONFIG_PARENT => __('Inherit', 'moreoptions')] : []) + self::getSelectableProfiles(), + 'profiles_values' => self::getProfilesValues($moconfig), + 'escalation_tree' => Group_Link::getEditorVariables($item), 'params' => [ 'canedit' => self::canUpdate(), ], @@ -361,6 +433,38 @@ public static function getIcon(): string return "ti ti-send"; } + /** + * Choices of a `profiles` field: every profile, by name. + * + * @return array + */ + private static function getSelectableProfiles(): array + { + return array_map( + static fn(array $profile): string => (string) $profile['name'], + (new Profile())->find([], 'name'), + ); + } + + /** + * Values selected in the `profiles` fields of the given configuration: Config::CONFIG_PARENT + * alone when inherited. + * + * @return array> + */ + private static function getProfilesValues(self $moconfig): array + { + $values = []; + foreach (self::getProfilesConfigFields() as $field) { + $value = $moconfig->fields[$field] ?? null; + $values[$field] = $value !== null && (int) $value === self::CONFIG_PARENT + ? [self::CONFIG_PARENT] + : self::decodeProfiles($value); + } + + return $values; + } + /** * The four tabs the config screen is split into. * @@ -374,6 +478,7 @@ public static function getScreenTabs(): array ['id' => 'problem', 'label' => __('Problem'), 'icon' => Problem::getIcon()], ['id' => 'task', 'label' => _n('Task', 'Tasks', 2), 'icon' => 'ti-checklist'], ['id' => 'escalate', 'label' => __('Escalate', 'moreoptions'), 'icon' => Escalation::getIcon(),], + ['id' => 'escalate_filter', 'label' => __('Escalate filter', 'moreoptions'), 'icon' => 'ti-filter'], ]; } @@ -461,6 +566,16 @@ private static function getScreenSections(): array ], ], ], + 'escalate_filter' => [ + [ + 'title' => __('Group filter', 'moreoptions'), + 'icon' => 'ti-filter', + 'rows' => [ + ['key' => 'escalate_group_filter_is_active', 'kind' => 'yes_no', 'label' => __('Activate group filter', 'moreoptions')], + ['key' => 'escalate_group_filter_bypass_profiles', 'kind' => 'profiles', 'label' => __('Profiles allowed to bypass the filter', 'moreoptions')], + ], + ], + ], ]; } @@ -473,7 +588,7 @@ private static function getScreenSections(): array */ private static function getSectionsForTab(string $tab_id): array { - if ($tab_id === 'task' || $tab_id === 'escalate') { + if (in_array($tab_id, ['task', 'escalate', 'escalate_filter'], true)) { $group = $tab_id; $suffix = ''; } else { @@ -536,6 +651,13 @@ private static function getParentValueBadges(Entity $item): array $badges[$field] = Entity::inheritedValue(htmlescape($text), false, false); } + $profile_names = self::getSelectableProfiles(); + foreach (self::getProfilesConfigFields() as $field) { + $names = array_intersect_key($profile_names, array_flip(self::decodeProfiles($parent_config->fields[$field] ?? null))); + $text = $names !== [] ? implode(', ', $names) : __('None'); + $badges[$field] = Entity::inheritedValue(htmlescape($text), false, false); + } + return $badges; } @@ -545,7 +667,7 @@ public static function addConfig(CommonDBTM $item): void $entity_id = $item->getID(); $data = ['entities_id' => $entity_id]; if ($entity_id > 0) { - foreach (self::getAllConfigFields() as $field) { + foreach (self::getInheritableConfigFields() as $field) { $data[$field] = self::CONFIG_PARENT; } } @@ -574,7 +696,7 @@ public static function getConfig(?int $entityId = null, bool $useInheritance = t $entity = new Entity(); if ($entity->getFromDB($entityId)) { $parentConfig = self::getConfig((int) $entity->fields['entities_id'], true); - foreach (self::getAllConfigFields() as $field) { + foreach (self::getInheritableConfigFields() as $field) { if (($moconfig->fields[$field] ?? 0) == self::CONFIG_PARENT) { $moconfig->fields[$field] = $parentConfig->fields[$field] ?? 0; } @@ -642,6 +764,8 @@ public static function install(Migration $migration): void `escalade_status_after_escalation_problem` tinyint NOT NULL DEFAULT '0', `escalade_assign_me_as_obsever_by_default` tinyint NOT NULL DEFAULT '0', `escalade_is_private_by_default` tinyint NOT NULL DEFAULT '0', + `escalate_group_filter_is_active` tinyint NOT NULL DEFAULT '0', + `escalate_group_filter_bypass_profiles` text, PRIMARY KEY (`id`), KEY `entities_id` (`entities_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; @@ -675,20 +799,31 @@ public static function install(Migration $migration): void } // Status fields added to an existing table: child entities inherit by default. - $new_status_fields = []; + $new_inherited_fields = []; foreach (array_keys(self::getStatusConfigFields()) as $field) { if (!$DB->fieldExists($table, $field)) { $migration->addField($table, $field, 'bool', ['value' => '0']); - $new_status_fields[] = $field; + $new_inherited_fields[] = $field; } } + // Same for the group filter fields. + if (!$DB->fieldExists($table, 'escalate_group_filter_is_active')) { + $migration->addField($table, 'escalate_group_filter_is_active', 'bool', ['value' => '0']); + $new_inherited_fields[] = 'escalate_group_filter_is_active'; + } + + if (!$DB->fieldExists($table, 'escalate_group_filter_bypass_profiles')) { + $migration->addField($table, 'escalate_group_filter_bypass_profiles', 'text'); + $new_inherited_fields[] = 'escalate_group_filter_bypass_profiles'; + } + $migration->executeMigration(); - if ($new_status_fields !== []) { + if ($new_inherited_fields !== []) { $DB->update( $table, - array_fill_keys($new_status_fields, self::CONFIG_PARENT), + array_fill_keys($new_inherited_fields, self::CONFIG_PARENT), ['entities_id' => ['>', 0]], ); } @@ -703,7 +838,7 @@ public static function install(Migration $migration): void $data = ['entities_id' => $entity_id]; if ($entity_id > 0) { - foreach (self::getAllConfigFields() as $field) { + foreach (self::getInheritableConfigFields() as $field) { $data[$field] = self::CONFIG_PARENT; } } diff --git a/src/Escalation.php b/src/Escalation.php index 64a9a17..f582bad 100644 --- a/src/Escalation.php +++ b/src/Escalation.php @@ -153,9 +153,51 @@ private static function getEscalationsOf(CommonITILObject $item, string $order = return (new self())->find($criterias, $order); } + /** + * Whether the group filter of the entity of the item applies to the current user: the option is + * active, and the active profile of the user is not allowed to bypass it. + */ + public static function isGroupFilterApplied(CommonITILObject $item): bool + { + $config = Config::getConfig((int) $item->fields['entities_id']); + if ((int) ($config->fields['escalate_group_filter_is_active'] ?? 0) !== 1) { + return false; + } + + $bypass_profiles = Config::decodeProfiles($config->fields['escalate_group_filter_bypass_profiles'] ?? null); + + return !in_array((int) ($_SESSION['glpiactiveprofile']['id'] ?? 0), $bypass_profiles, true); + } + + /** + * Groups the item can be escalated to when the group filter applies (see + * self::isGroupFilterApplied()): the groups of the next level of the escalation tree (see + * Group_Link::getNextLevelGroupsOf()) from the groups assigned to the item. + * + * Null, any group being allowed, when the filter does not apply, or when no group is assigned + * to the item: there is no level to start from. + * + * @return list|null Ids of the groups + */ + public static function getGroupFilter(CommonITILObject $item): ?array + { + if (!self::isGroupFilterApplied($item)) { + return null; + + } + + $assigned_groups = self::getAssignedGroupIds($item); + if ($assigned_groups === []) { + return null; + } + + return Group_Link::getNextLevelGroupsOf($assigned_groups, (int) $item->fields['entities_id']); + } + /** * Why the item cannot be escalated to the given group, or null when it can: the group must - * exist, be assignable, be visible from the item entity and not be already assigned to the item. + * exist, be assignable, be visible from the item entity, not be already assigned to the item + * and be allowed by the group filter (see self::getGroupFilter()). */ public static function getEscalationBlocker(CommonITILObject $item, int $groups_id): ?string { @@ -184,11 +226,16 @@ public static function getEscalationBlocker(CommonITILObject $item, int $groups_ return __('This group is already assigned.', 'moreoptions'); } + $allowed_groups = self::getGroupFilter($item); + if ($allowed_groups !== null && !in_array($groups_id, $allowed_groups, true)) { + return __('This group is not in the next level of the escalation tree.', 'moreoptions'); + } + return null; } /** - * @return array + * @return list */ private static function getAssignedGroupIds(CommonITILObject $item): array { @@ -456,9 +503,18 @@ public static function showEscalationForm(CommonITILObject $item): void $config = Config::getConfig((int) $item->fields['entities_id']); + $group_condition = ['is_assign' => 1]; + $allowed_groups = self::getGroupFilter($item); + if ($allowed_groups !== null) { + // No group in the next level: the "0" id matches none + $group_condition['id'] = $allowed_groups !== [] ? $allowed_groups : [0]; + } + TemplateRenderer::getInstance()->display('@moreoptions/escalation_form.html.twig', [ 'item' => $item, 'groups_used' => $groups_used ?? [], + 'group_condition' => $group_condition, + 'no_allowed_group' => $allowed_groups === [], // Default values of the form options 'config' => [ 'assign_to_observer' => (int) ($config->fields['escalade_assign_me_as_obsever_by_default'] ?? 0) === 1, diff --git a/src/EscalationTree/Action/AbstractTreeAction.php b/src/EscalationTree/Action/AbstractTreeAction.php new file mode 100644 index 0000000..269c771 --- /dev/null +++ b/src/EscalationTree/Action/AbstractTreeAction.php @@ -0,0 +1,63 @@ + $params Parameters sent by the page, single values (see TreeEditor::respond()) + */ + abstract public function apply(TreeEditor $editor, array $params): void; +} diff --git a/src/EscalationTree/Action/AbstractViewAction.php b/src/EscalationTree/Action/AbstractViewAction.php new file mode 100644 index 0000000..4ae4d26 --- /dev/null +++ b/src/EscalationTree/Action/AbstractViewAction.php @@ -0,0 +1,47 @@ +getSelectedNode(); + $direction = (string) ($params['direction'] ?? ''); + if ($selected === null || !in_array($direction, EscalationLink::DIRECTIONS, true)) { + return; + } + + $other = (int) ($params['_link_' . $direction . '_group'] ?? 0); + $strategy = LinkStrategyEnum::tryFromDrawn((string) ($params['_link_' . $direction . '_strategy'] ?? '')) ?? LinkStrategyEnum::BASIC; + [$from, $to] = $direction === 'to' ? [$selected, $other] : [$other, $selected]; + + // The group chosen is placed for the link: it stays placed if the link is refused, as only + // a link it already has can make a loop. + $tree = $editor->getTree(); + $tree->addNode($other); + + $link = $tree->link($from, $to); + if (!$link instanceof EscalationLink) { + $editor->refuseLink($from, $to); + return; + } + + $tree->setStrategy($link->getKey(), $strategy); + } +} diff --git a/src/EscalationTree/Action/AddNodeAction.php b/src/EscalationTree/Action/AddNodeAction.php new file mode 100644 index 0000000..c5234ad --- /dev/null +++ b/src/EscalationTree/Action/AddNodeAction.php @@ -0,0 +1,59 @@ +getTree()->getUnplacedGroups()[$group])) { + $editor->getTree()->addNode($group); + $editor->select(); + $editor->scrollTo($group); + } + } +} diff --git a/src/EscalationTree/Action/ClearAction.php b/src/EscalationTree/Action/ClearAction.php new file mode 100644 index 0000000..c030609 --- /dev/null +++ b/src/EscalationTree/Action/ClearAction.php @@ -0,0 +1,53 @@ +select(); + } +} diff --git a/src/EscalationTree/Action/DeleteLinkAction.php b/src/EscalationTree/Action/DeleteLinkAction.php new file mode 100644 index 0000000..2f6bb65 --- /dev/null +++ b/src/EscalationTree/Action/DeleteLinkAction.php @@ -0,0 +1,63 @@ +getSelectedLink(); + if ($link === null) { + return; + } + + $editor->getTree()->unlink($link); + if ($editor->getSelectedNode() === null || $given === '') { + $editor->select(); + } + } +} diff --git a/src/EscalationTree/Action/DeleteSelectionAction.php b/src/EscalationTree/Action/DeleteSelectionAction.php new file mode 100644 index 0000000..0d1fa4a --- /dev/null +++ b/src/EscalationTree/Action/DeleteSelectionAction.php @@ -0,0 +1,60 @@ +getTree(); + if ($editor->getSelectedLink() !== null) { + $tree->unlink($editor->getSelectedLink()); + } elseif ($editor->getSelectedNode() !== null) { + $tree->removeNode($editor->getSelectedNode()); + } + + $editor->select(); + } +} diff --git a/src/EscalationTree/Action/DrawingStrategyAction.php b/src/EscalationTree/Action/DrawingStrategyAction.php new file mode 100644 index 0000000..574af3d --- /dev/null +++ b/src/EscalationTree/Action/DrawingStrategyAction.php @@ -0,0 +1,57 @@ +setDrawingStrategy($strategy); + } + } +} diff --git a/src/EscalationTree/Action/LinkAction.php b/src/EscalationTree/Action/LinkAction.php new file mode 100644 index 0000000..b9b9677 --- /dev/null +++ b/src/EscalationTree/Action/LinkAction.php @@ -0,0 +1,75 @@ +select(); + return; + } + + $tree = $editor->getTree(); + $existed = $tree->getLink(EscalationLink::key($from, $to)) instanceof EscalationLink; + $link = $tree->link($from, $to); + if (!$link instanceof EscalationLink) { + $editor->refuseLink($from, $to); + return; + } + + if (!$existed && !$link->isReplicated()) { + $tree->setStrategy($link->getKey(), $editor->getDrawingStrategy()); + } + + $editor->select(link: $link->getKey()); + } +} diff --git a/src/EscalationTree/Action/OrientationAction.php b/src/EscalationTree/Action/OrientationAction.php new file mode 100644 index 0000000..7fa24f6 --- /dev/null +++ b/src/EscalationTree/Action/OrientationAction.php @@ -0,0 +1,53 @@ +setOrientation((string) ($params['value'] ?? '')); + } +} diff --git a/src/EscalationTree/Action/RemoveNodeAction.php b/src/EscalationTree/Action/RemoveNodeAction.php new file mode 100644 index 0000000..1331a7a --- /dev/null +++ b/src/EscalationTree/Action/RemoveNodeAction.php @@ -0,0 +1,54 @@ +getTree()->removeNode((int) ($params['group'] ?? 0)); + $editor->select(); + } +} diff --git a/src/EscalationTree/Action/RenderAction.php b/src/EscalationTree/Action/RenderAction.php new file mode 100644 index 0000000..a85c248 --- /dev/null +++ b/src/EscalationTree/Action/RenderAction.php @@ -0,0 +1,53 @@ +getTree()->reset(); + $editor->select(); + } +} diff --git a/src/EscalationTree/Action/SelectLinkAction.php b/src/EscalationTree/Action/SelectLinkAction.php new file mode 100644 index 0000000..7989b1c --- /dev/null +++ b/src/EscalationTree/Action/SelectLinkAction.php @@ -0,0 +1,57 @@ +getTree()->getLink($link) instanceof EscalationLink) { + $editor->select(link: $link); + } + } +} diff --git a/src/EscalationTree/Action/SelectNodeAction.php b/src/EscalationTree/Action/SelectNodeAction.php new file mode 100644 index 0000000..0f46821 --- /dev/null +++ b/src/EscalationTree/Action/SelectNodeAction.php @@ -0,0 +1,56 @@ +getTree()->hasNode($group)) { + $editor->select(node: $group); + } + } +} diff --git a/src/EscalationTree/Action/SetLinkTypeAction.php b/src/EscalationTree/Action/SetLinkTypeAction.php new file mode 100644 index 0000000..b04ce10 --- /dev/null +++ b/src/EscalationTree/Action/SetLinkTypeAction.php @@ -0,0 +1,60 @@ +getSelectedLink(); + $strategy = LinkStrategyEnum::tryFromDrawn((string) ($params['value'] ?? '')); + if ($link !== null && $strategy instanceof LinkStrategyEnum) { + $editor->getTree()->setStrategy($link, $strategy); + } + } +} diff --git a/src/EscalationTree/Action/TreeActionRegistry.php b/src/EscalationTree/Action/TreeActionRegistry.php new file mode 100644 index 0000000..b4515ec --- /dev/null +++ b/src/EscalationTree/Action/TreeActionRegistry.php @@ -0,0 +1,95 @@ + + */ + private static array $registered = []; + + /** + * Adds an action, or replaces the one of the same name. + */ + public static function register(AbstractTreeAction $action): void + { + self::$registered[$action::getName()] = $action; + } + + /** + * The action of the given name, null for an unknown one. + */ + public static function get(string $name): ?AbstractTreeAction + { + return self::getActions()[$name] ?? null; + } + + /** + * @return array By name + */ + public static function getActions(): array + { + $actions = []; + foreach ( + [ + new RenderAction(), + new ClearAction(), + new SelectNodeAction(), + new SelectLinkAction(), + new OrientationAction(), + new DrawingStrategyAction(), + new AddNodeAction(), + new RemoveNodeAction(), + new LinkAction(), + new AddLinkAction(), + new SetLinkTypeAction(), + new DeleteLinkAction(), + new DeleteSelectionAction(), + new ResetAction(), + ] as $action + ) { + $actions[$action::getName()] = $action; + } + + return self::$registered + $actions; + } +} diff --git a/src/EscalationTree/EscalationGraph.php b/src/EscalationTree/EscalationGraph.php new file mode 100644 index 0000000..bb6219f --- /dev/null +++ b/src/EscalationTree/EscalationGraph.php @@ -0,0 +1,238 @@ +> + */ + private array $children = []; + + /** + * Groups escalating to each group, in the order of the edges. + * + * @var array> + */ + private array $parents = []; + + /** + * @param list $nodes Groups of the graph, in order: the groups of the edges are added after them + * @param iterable $edges Source and destination of each edge + */ + public function __construct(array $nodes = [], iterable $edges = []) + { + foreach ($nodes as $node) { + $this->addNode($node); + } + + foreach ($edges as [$source, $destination]) { + $this->addEdge($source, $destination); + } + } + + /** + * @param list $nodes + * @param iterable $links + */ + public static function fromLinks(array $nodes, iterable $links): self + { + $edges = []; + foreach ($links as $link) { + $edges[] = [$link->source, $link->destination]; + } + + return new self($nodes, $edges); + } + + public function addNode(int $node): void + { + $this->children[$node] ??= []; + $this->parents[$node] ??= []; + } + + /** + * Adds an edge, unless already there. + */ + public function addEdge(int $source, int $destination): void + { + $this->addNode($source); + $this->addNode($destination); + if (!in_array($destination, $this->children[$source], true)) { + $this->children[$source][] = $destination; + $this->parents[$destination][] = $source; + } + } + + /** + * @return list + */ + public function getChildren(int $node): array + { + return $this->children[$node] ?? []; + } + + /** + * @return list + */ + public function getParents(int $node): array + { + return $this->parents[$node] ?? []; + } + + /** + * Groups escalating to the given one, directly or not. + * + * @return array By group id + */ + public function getAncestors(int $node): array + { + return $this->reach($node, $this->parents); + } + + /** + * Groups the given one escalates to, directly or not. + * + * @return array By group id + */ + public function getDescendants(int $node): array + { + return $this->reach($node, $this->children); + } + + /** + * Level of each group: 1 for the groups nobody escalates to, then the longest path from them. + * The groups of a loop, and the ones after it, are at level 1. + * + * @return array By group id, in the order of the groups + */ + public function getLevels(): array + { + $levels = array_fill_keys(array_keys($this->children), 1); + $sorted = array_flip($this->sortTopologically()); + foreach (array_keys($sorted) as $node) { + foreach ($this->children[$node] as $child) { + // The groups after a loop stay at level 1: some of their parents are not sorted. + if (isset($sorted[$child])) { + $levels[$child] = max($levels[$child], $levels[$node] + 1); + } + } + } + + return $levels; + } + + /** + * A loop of the graph: groups escalating, directly or not, to themselves. + * + * @return list|null The groups of the first loop found, in the order of the escalation + */ + public function findCycle(): ?array + { + $sorted = array_flip($this->sortTopologically()); + $left = array_diff_key($this->children, $sorted); + if ($left === []) { + return null; + } + + // Each group left has a parent left: going up from one of them, a group comes back. + $path = []; + $position = []; + $node = array_key_first($left); + while (!isset($position[$node])) { + $position[$node] = count($path); + $path[] = $node; + foreach ($this->parents[$node] as $parent) { + if (isset($left[$parent])) { + $node = $parent; + break; + } + } + } + + return array_reverse(array_slice($path, $position[$node])); + } + + /** + * Groups in an order where each group comes after the groups escalating to it (Kahn's + * algorithm). The groups of a loop, and the ones after it, are left out. + * + * @return list + */ + private function sortTopologically(): array + { + // Parents of each group not sorted yet: a group is sorted once all its parents are. + $remaining = array_map(count(...), $this->parents); + $sorted = array_keys($remaining, 0, true); + // The list grows while it is read: each group sorted may let its children be sorted. + for ($i = 0; isset($sorted[$i]); $i++) { + foreach ($this->children[$sorted[$i]] as $child) { + if (--$remaining[$child] === 0) { + $sorted[] = $child; + } + } + } + + return $sorted; + } + + /** + * Groups reached from the given one, following the given edges. + * + * @param array> $edges + * @return array + */ + private function reach(int $node, array $edges): array + { + $reached = []; + $stack = $edges[$node] ?? []; + while ($stack !== []) { + $next = array_pop($stack); + if (!isset($reached[$next])) { + $reached[$next] = true; + array_push($stack, ...$edges[$next]); + } + } + + return $reached; + } +} diff --git a/src/EscalationTree/EscalationLink.php b/src/EscalationTree/EscalationLink.php new file mode 100644 index 0000000..0c9e936 --- /dev/null +++ b/src/EscalationTree/EscalationLink.php @@ -0,0 +1,155 @@ + $row + */ + public static function fromRow(array $row, ?string $origin = null): self + { + return new self( + (int) $row['groups_id_source'], + (int) $row['groups_id_destination'], + LinkStrategyEnum::fromValue($row['link_type']), + $origin, + isset($row['entities_id']) ? (int) $row['entities_id'] : null, + ); + } + + /** + * Identifies the link in its tree: a tree holds one link at most between two groups, in each direction. + */ + public static function key(int $source, int $destination): string + { + return $source . '-' . $destination; + } + + public function getKey(): string + { + return self::key($this->source, $this->destination); + } + + /** + * The fields of the link in the links table (see Group_Link). + * + * @return array{groups_id_source: int, groups_id_destination: int, link_type: string} + */ + public function toRow(): array + { + return [ + 'groups_id_source' => $this->source, + 'groups_id_destination' => $this->destination, + 'link_type' => $this->strategy->value, + ]; + } + + /** + * The same link, replicated from the parent entity of the given name. + */ + public function withOrigin(string $origin): self + { + return new self($this->source, $this->destination, $this->strategy, $origin, $this->entities_id); + } + + /** + * The link applying in an entity, among the links of a pair of groups stored in the entity and + * in its parent entities. From the deepest entity, the first link applying wins: + * - a link of the entity itself; + * - a link of a parent entity applying to its sub-entities (see LinkStrategyEnum::INHERITED), + * a LinkStrategyEnum::BASIC one only applying in its own entity. + * A link blocking the inheritance (see LinkStrategyEnum::NONE) stops the search: no link + * applies, in its entity and its sub-entities. + * + * @param list $links Links of the pair, from the deepest entity + * @param array $entities Entities whose links are searched, as keys: the others are ignored + */ + public static function resolve(array $links, int $entities_id, array $entities): ?self + { + foreach ($links as $link) { + if (!isset($entities[$link->entities_id])) { + continue; + } + + $strategy = $link->strategy->getStrategy(); + if ($strategy->blocksInheritance()) { + return null; + } + + if ($link->entities_id === $entities_id || $strategy->appliesToSubEntities()) { + return $link; + } + } + + return null; + } + + public function isReplicated(): bool + { + return $this->origin !== null; + } + + public function withStrategy(LinkStrategyEnum $strategy): self + { + return new self($this->source, $this->destination, $strategy, $this->origin, $this->entities_id); + } +} diff --git a/src/EscalationTree/EscalationTree.php b/src/EscalationTree/EscalationTree.php new file mode 100644 index 0000000..e4f893c --- /dev/null +++ b/src/EscalationTree/EscalationTree.php @@ -0,0 +1,570 @@ + + */ + private array $nodes = []; + + /** + * Links of the entity, by key. + * + * @var array + */ + private array $links = []; + + /** + * Keys of the replicated links removed from the entity. + * + * @var array + */ + private array $removed = []; + + /** + * Whether the tree was reset (see reset()): its saved links are all replaced. + */ + private bool $reset = false; + + /** + * Version of the saved links the tree was made from (see Group_Link::getVersion()). + */ + private string $version = ''; + + /** + * Cache of getLinks(): the links shown in the tree, computed again after each change (null). + * + * @var array|null + */ + private ?array $links_cache = null; + + /** + * Cache of getGraph(), computed again after each change (null). + */ + private ?EscalationGraph $graph = null; + + /** + * @param array $linkable_groups Groups that can be linked in the entity: names, by id. The replicated links are between these groups too. + * @param array $replicated Links replicated from the parent entities, by key, including the removed ones + */ + private function __construct( + public readonly int $entities_id, + private readonly array $linkable_groups, + private readonly array $replicated, + ) {} + + /** + * The tree as saved: the groups of the links applying in the entity. + * + * @param list $first_groups Groups to place first, even without link + */ + public static function load(int $entities_id, array $first_groups = []): self + { + $tree = self::forEntity($entities_id); + foreach ($first_groups as $id) { + $tree->addNode($id); + } + + $rows = Group_Link::getRowsOfEntity($entities_id); + $tree->version = Group_Link::getVersion($rows); + foreach ($rows as $row) { + $link = EscalationLink::fromRow($row); + $key = $link->getKey(); + if ($link->strategy === LinkStrategyEnum::NONE) { + if (isset($tree->replicated[$key])) { + $tree->removed[$key] = true; + } + } elseif ($tree->isLinkable($link->source) && $tree->isLinkable($link->destination)) { + // Both groups or none: a group is not placed for a link that is not. + $tree->addNode($link->source); + $tree->addNode($link->destination); + $tree->links[$key] = $link; + } + } + + foreach ($tree->getReplicatedLinksApplying() as $link) { + $tree->addNode($link->source); + $tree->addNode($link->destination); + } + + $tree->changed(); + + return $tree; + } + + /** + * The tree being edited, as saved by toState(). Everything is checked again: unknown groups, + * links or strategies, links making a loop, and links removed, are ignored. + * + * @param array $state + */ + public static function fromState(int $entities_id, array $state): self + { + $tree = self::forEntity($entities_id); + $tree->version = is_string($state['version'] ?? null) ? $state['version'] : ''; + $tree->reset = ($state['reset'] ?? false) === true; + + foreach ((array) ($state['nodes'] ?? []) as $id) { + if (is_numeric($id)) { + $tree->addNode((int) $id); + } + } + + foreach ((array) ($state['removed'] ?? []) as $key) { + if (is_string($key) && isset($tree->replicated[$key])) { + $tree->removed[$key] = true; + } + } + + $tree->changed(); + + // Each link added is added to the graph too, for the next ones to be checked against it. + $graph = $tree->getGraph(); + foreach (array_slice((array) ($state['links'] ?? []), 0, self::MAX_LINKS) as $link) { + if (!is_array($link)) { + continue; + } + + [$source, $destination] = [(int) ($link['from'] ?? 0), (int) ($link['to'] ?? 0)]; + $key = EscalationLink::key($source, $destination); + $strategy = is_string($link['type'] ?? null) ? LinkStrategyEnum::tryFromDrawn($link['type']) : null; + // A removed replicated link is restored by link() only: it is not a link of the entity. + if ( + $strategy instanceof LinkStrategyEnum && !isset($tree->links[$key]) && !isset($tree->removed[$key]) + && $tree->canLink($source, $destination) + ) { + $tree->links[$key] = new EscalationLink($source, $destination, $strategy); + $graph->addEdge($source, $destination); + } + } + + // The links shown so far stay valid for canLink(): the links added are checked by key. + $tree->links_cache = null; + + return $tree; + } + + /** + * An empty tree for the entity, with the groups that can be linked in it and the links + * replicated from its parent entities. + */ + private static function forEntity(int $entities_id): self + { + $entity_names = []; + $replicated = []; + foreach (Group_Link::getInheritedLinks($entities_id) as $link) { + $origin = (int) $link->entities_id; + $entity_names[$origin] ??= Group_Link::getEntityName($origin); + $replicated[$link->getKey()] = $link->withOrigin($entity_names[$origin]); + } + + return new self($entities_id, Group_Link::getGroupsForEntity($entities_id), $replicated); + } + + /** + * The tree being edited, as read by fromState(). + * + * @return array{version: string, reset: bool, nodes: list, links: list, removed: list} + */ + public function toState(): array + { + $links = []; + foreach ($this->links as $link) { + $links[] = ['from' => $link->source, 'to' => $link->destination, 'type' => $link->strategy->value]; + } + + return [ + 'version' => $this->version, + 'reset' => $this->reset, + 'nodes' => array_keys($this->nodes), + 'links' => $links, + 'removed' => array_keys($this->removed), + ]; + } + + /** + * Saves the links of the entity, plus a LinkStrategyEnum::NONE link for each replicated link + * removed. The ones that apply again (restored) lose their LinkStrategyEnum::NONE link. + * + * Nothing is saved if the links would make a loop in a sub-entity: with the links of the + * sub-entity, or the ones it gets from another parent entity. + * + * @return array{entities_id: int, groups: list}|null The loop preventing the save, see Group_Link::findLoop() + */ + public function save(): ?array + { + /** @var DBmysql $DB */ + global $DB; + + $links = array_values($this->links); + foreach (array_keys($this->removed) as $key) { + $links[] = $this->replicated[$key]->withStrategy(LinkStrategyEnum::NONE); + } + + $DB->beginTransaction(); + try { + Group_Link::saveLinksForEntity( + $this->entities_id, + $links, + array_keys(array_diff_key($this->replicated, $this->removed)), + $this->reset, + $this->linkable_groups, + array_keys($this->replicated), + ); + $loop = Group_Link::findLoop($this->entities_id); + } catch (Throwable $throwable) { + $DB->rollBack(); + throw $throwable; + } + + if ($loop !== null) { + $DB->rollBack(); + return $loop; + } + + $DB->commit(); + + return null; + } + + /** + * Whether the saved links are still the ones the tree was made from: nobody saved others since. + */ + public function isUpToDate(): bool + { + return hash_equals(Group_Link::getVersion(Group_Link::getRowsOfEntity($this->entities_id)), $this->version); + } + + /** + * Resets the tree: removes all the links of the entity, which gets back the links replicated + * from its parent entities, removed or replaced in it until now. Only the groups of these + * links stay placed. In the root entity, no link is left. + */ + public function reset(): void + { + $this->links = []; + $this->removed = []; + $this->reset = true; + $placed = []; + foreach ($this->replicated as $link) { + $placed[$link->source] = true; + $placed[$link->destination] = true; + } + + $this->nodes = array_intersect_key($this->nodes, $placed); + foreach (array_keys($placed) as $id) { + $this->addNode($id); + } + + $this->changed(); + } + + /** + * Whether resetting the tree would change it: it has links of the entity, or replicated links + * removed from it. + */ + public function canReset(): bool + { + return $this->links !== [] || $this->removed !== []; + } + + /** + * @return list In placement order + */ + public function getNodes(): array + { + return array_values($this->nodes); + } + + public function getNode(int $id): ?GroupNode + { + return $this->nodes[$id] ?? null; + } + + public function hasNode(int $id): bool + { + return isset($this->nodes[$id]); + } + + /** + * Whether the group can be linked in the entity: assignable there (see Group_Link::getGroupsForEntity()). + */ + public function isLinkable(int $id): bool + { + return isset($this->linkable_groups[$id]); + } + + /** + * Groups that can be linked in the entity and are not placed yet: names, by id. + * + * @return array + */ + public function getUnplacedGroups(): array + { + return array_diff_key($this->linkable_groups, $this->nodes); + } + + /** + * Places a group that can be linked in the entity. + * + * @return GroupNode|null The node of the group, null for a group that cannot be placed + */ + public function addNode(int $id): ?GroupNode + { + if (!$this->hasNode($id) && $this->isLinkable($id)) { + $this->nodes[$id] = new GroupNode($id, $this->linkable_groups[$id], $this); + $this->changed(); + } + + return $this->nodes[$id] ?? null; + } + + /** + * Removes a group, and its links: its links of the entity are deleted, and its replicated + * links removed from the entity, including the ones its links of the entity replaced. + */ + public function removeNode(int $id): void + { + foreach (array_keys($this->links + $this->replicated) as $key) { + $link = $this->links[$key] ?? $this->replicated[$key]; + if ($link->source === $id || $link->destination === $id) { + unset($this->links[$key]); + if (isset($this->replicated[$key])) { + $this->removed[$key] = true; + } + } + } + + unset($this->nodes[$id]); + $this->changed(); + } + + /** + * Links shown in the tree: the links of the entity, and the replicated ones still applying. + * + * @return array By key + */ + public function getLinks(): array + { + if ($this->links_cache === null) { + $this->links_cache = $this->links; + foreach ($this->getReplicatedLinksApplying() as $key => $link) { + if ($this->hasNode($link->source) && $this->hasNode($link->destination)) { + $this->links_cache[$key] = $link; + } + } + } + + return $this->links_cache; + } + + /** + * Links from the group (`to`: to the groups it escalates to), or to the group (`from`: from + * the groups escalating to it). + * + * @return array By key + */ + public function getLinksOf(int $id, string $direction): array + { + $links = []; + foreach ($this->getLinks() as $key => $link) { + if (($direction === 'to' ? $link->source : $link->destination) === $id) { + $links[$key] = $link; + } + } + + return $links; + } + + public function getLink(string $key): ?EscalationLink + { + return $this->getLinks()[$key] ?? null; + } + + /** + * The groups placed and the links shown, as a graph: their parents, children, levels... + */ + public function getGraph(): EscalationGraph + { + return $this->graph ??= EscalationGraph::fromLinks(array_keys($this->nodes), $this->getLinks()); + } + + /** + * Whether the source group can be linked to the destination group: both are placed, and the + * link would not make a loop (the destination does not escalate to the source). + */ + public function canLink(int $source, int $destination): bool + { + if (!$this->hasNode($source) || !$this->hasNode($destination) || $source === $destination) { + return false; + } + + if ($this->getLink(EscalationLink::key($source, $destination)) instanceof EscalationLink) { + return true; + } + + return !$this->nodes[$destination]->isAncestorOf($this->nodes[$source]); + } + + /** + * Groups the given group can be linked to (`to`) or from (`from`): the placed groups it is not + * linked with yet, and that would not make a loop, then the groups not placed yet. + * + * @return array Names, by group id + */ + public function getLinkCandidates(int $id, string $direction): array + { + // Linked the other way round, they would make a loop: escalating to a group the given + // one escalates from, directly or not, or the reverse. + $node = $this->nodes[$id] ?? null; + $forbidden = $node === null ? [] : ($direction === 'to' ? $node->getAncestors() : $node->getDescendants()); + + $candidates = []; + foreach ($this->nodes as $other => $node) { + [$from, $to] = $direction === 'to' ? [$id, $other] : [$other, $id]; + if ($other !== $id && !isset($forbidden[$other]) && !$this->getLink(EscalationLink::key($from, $to)) instanceof EscalationLink) { + $candidates[$other] = $node->name; + } + } + + return $candidates + $this->getUnplacedGroups(); + } + + /** + * Links the source group to the destination group: restores the replicated link removed + * between them, if any, or adds a LinkStrategyEnum::BASIC one. + * + * @return EscalationLink|null The link between them, null if they cannot be linked (see canLink()) + */ + public function link(int $source, int $destination): ?EscalationLink + { + if (!$this->canLink($source, $destination)) { + return null; + } + + $key = EscalationLink::key($source, $destination); + if (!$this->getLink($key) instanceof EscalationLink) { + if (isset($this->removed[$key])) { + unset($this->removed[$key]); + } else { + $this->links[$key] = new EscalationLink($source, $destination, LinkStrategyEnum::BASIC); + } + + $this->changed(); + } + + return $this->getLink($key); + } + + /** + * Removes a link: deletes a link of the entity, or removes a replicated one from the entity. + * A link of the entity replacing a replicated one gives way to it. + */ + public function unlink(string $key): void + { + if (isset($this->links[$key])) { + unset($this->links[$key]); + } elseif (isset($this->replicated[$key])) { + $this->removed[$key] = true; + } + + $this->changed(); + } + + /** + * Changes the strategy of a link of the entity. A replicated link cannot be changed. + */ + public function setStrategy(string $key, LinkStrategyEnum $strategy): void + { + if (isset($this->links[$key]) && $strategy->getStrategy()->isDrawn()) { + $this->links[$key] = $this->links[$key]->withStrategy($strategy); + $this->changed(); + } + } + + /** + * Level of each group: 1 for the groups nobody escalates to, then the longest path from them. + * + * @return array By group id, in placement order + */ + public function getLevels(): array + { + return $this->getGraph()->getLevels(); + } + + /** + * Replicated links neither removed nor replaced by a link of the entity. + * + * @return array + */ + private function getReplicatedLinksApplying(): array + { + return array_diff_key($this->replicated, $this->removed, $this->links); + } + + /** + * Forgets what is computed from the groups and links, after a change. + */ + private function changed(): void + { + $this->links_cache = null; + $this->graph = null; + } +} diff --git a/src/EscalationTree/GroupNode.php b/src/EscalationTree/GroupNode.php new file mode 100644 index 0000000..a353433 --- /dev/null +++ b/src/EscalationTree/GroupNode.php @@ -0,0 +1,136 @@ + + */ + public function getParents(): array + { + return $this->toNodes($this->tree->getGraph()->getParents($this->id)); + } + + /** + * Groups this one escalates to directly. + * + * @return list + */ + public function getChildren(): array + { + return $this->toNodes($this->tree->getGraph()->getChildren($this->id)); + } + + /** + * Groups escalating to this one, directly or not. + * + * @return array By group id + */ + public function getAncestors(): array + { + return $this->toNodesById($this->tree->getGraph()->getAncestors($this->id)); + } + + /** + * Groups this one escalates to, directly or not. + * + * @return array By group id + */ + public function getDescendants(): array + { + return $this->toNodesById($this->tree->getGraph()->getDescendants($this->id)); + } + + /** + * Whether this group escalates to the given one, directly or not. + */ + public function isAncestorOf(GroupNode $node): bool + { + return isset($this->tree->getGraph()->getAncestors($node->id)[$this->id]); + } + + /** + * Whether no group escalates to this one: a first level of the tree. + */ + public function isRoot(): bool + { + return $this->tree->getGraph()->getParents($this->id) === []; + } + + /** + * @param list $ids + * @return list + */ + private function toNodes(array $ids): array + { + return array_values($this->toNodesById(array_fill_keys($ids, true))); + } + + /** + * @param array $ids + * @return array + */ + private function toNodesById(array $ids): array + { + $nodes = []; + foreach (array_keys($ids) as $id) { + $node = $this->tree->getNode($id); + if ($node instanceof self) { + $nodes[$id] = $node; + } + } + + return $nodes; + } +} diff --git a/src/EscalationTree/TreeEditor.php b/src/EscalationTree/TreeEditor.php new file mode 100644 index 0000000..1380bfb --- /dev/null +++ b/src/EscalationTree/TreeEditor.php @@ -0,0 +1,380 @@ +canedit = Config::canUpdate(); + } + + /** + * The tree of the given entity as saved: the groups of its links. + * + * @param string|null $error Message to show, why the draft of the page was discarded for instance + * @param array $preferences See setPreferences() + */ + public static function fromDatabase(int $entities_id, ?string $error = null, array $preferences = []): self + { + $editor = new self(EscalationTree::load($entities_id)); + $editor->error = $error; + $editor->setPreferences($preferences); + + return $editor; + } + + /** + * The tree of the given entity being edited, as sent back by the page (see getState()). + * + * @param array $state + * @param array $preferences See setPreferences() + */ + public static function fromState(int $entities_id, array $state, array $preferences = []): self + { + $editor = new self(EscalationTree::fromState($entities_id, $state)); + $editor->setPreferences($preferences); + + $selected_node = (int) ($state['selected_node'] ?? 0); + $editor->selected_node = $editor->tree->hasNode($selected_node) ? $selected_node : null; + $selected_link = is_string($state['selected_link'] ?? null) ? $state['selected_link'] : ''; + $editor->selected_link = $editor->tree->getLink($selected_link) instanceof EscalationLink ? $selected_link : null; + + return $editor; + } + + /** + * Answers a request of the page (see ajax/group_link.php): applies a change to the draft sent, + * or saves it, and gives the editor to render. The draft is only applied to the entity it was + * made for, and only saved over the links it was made from. + * + * @param array $input `entities_id` (entity of the tree), `action` (see Action\TreeActionRegistry, or SAVE_ACTION), `state` (draft, see getState()), the preferences of the user (see setPreferences()), and the parameters of the action + * + * @throws AccessDeniedHttpException For an entity the user cannot see, or a save without the right to edit + * @throws BadRequestHttpException For a parameter that is not a single value, an unknown action or an invalid draft + */ + public static function respond(array $input): self + { + // The page only sends single values (the draft is a JSON string). + if (array_filter($input, is_array(...)) !== []) { + throw new BadRequestHttpException(); + } + + $entity = new Entity(); + if (!$entity->getFromDB((int) ($input['entities_id'] ?? -1)) || !$entity->can($entity->getID(), READ)) { + throw new AccessDeniedHttpException(); + } + + $entities_id = $entity->getID(); + + $name = (string) ($input['action'] ?? ''); + $action = $name === self::SAVE_ACTION ? null : (TreeActionRegistry::get($name) ?? throw new BadRequestHttpException()); + try { + $state = json_decode((string) ($input['state'] ?? ''), true); + } catch (JsonException) { + throw new BadRequestHttpException(); + } + + // The draft is only applied to the entity it was made for. + if (!is_array($state) || (int) ($state['entities_id'] ?? -1) !== $entities_id) { + throw new BadRequestHttpException(); + } + + $editor = self::fromState($entities_id, $state, $input); + if ($action instanceof AbstractTreeAction) { + $editor->apply($action, $input); + return $editor; + } + + if (!$editor->canedit) { + throw new AccessDeniedHttpException(); + } + + if (!$editor->tree->isUpToDate()) { + return self::fromDatabase( + $entities_id, + __('The links were saved by someone else meanwhile: the graph was reloaded, the unsaved changes were discarded.', 'moreoptions'), + $input, + ); + } + + if (!$editor->save()) { + return $editor; + } + + Session::addMessageAfterRedirect(__s('Escalation links saved.', 'moreoptions')); + + return self::fromDatabase($entities_id, null, $input); + } + + /** + * The draft, as sent back to fromState() by the page: the entity, the tree and the selection. + * + * @return array + */ + public function getState(): array + { + return ['entities_id' => $this->tree->entities_id] + $this->tree->toState() + [ + 'selected_node' => $this->selected_node, + 'selected_link' => $this->selected_link, + ]; + } + + /** + * Saves the draft, unless its links would make a loop in a sub-entity: the error is then shown, + * without naming what the user cannot see. + * + * @return bool Whether it was saved + */ + public function save(): bool + { + $loop = $this->tree->save(); + if ($loop === null) { + return true; + } + + // The groups of the loop, back to the first one + $names = []; + foreach (Group_Link::getGroupNames($loop['groups']) as $group) { + $names[] = $group['name']; + } + + $names[] = $names[0]; + + $this->error = sprintf( + __('The links were not saved: in the entity %1$s, they would make a loop between the groups %2$s.', 'moreoptions'), + Group_Link::getEntityName($loop['entities_id']), + implode(' → ', $names), + ); + + return false; + } + + /** + * Applies an action of the page to the draft. The actions changing the links are ignored + * without the right to edit them (see AbstractTreeAction::requiresEdit()). + * + * @param array $params Parameters sent by the page, single values (see respond()), with the `query` of the group search + */ + public function apply(AbstractTreeAction $action, array $params): void + { + if ($action->requiresEdit() && !$this->canedit) { + return; + } + + $this->query = trim((string) ($params['query'] ?? '')); + $action->apply($this, $params); + } + + public function getTree(): EscalationTree + { + return $this->tree; + } + + public function getSelectedNode(): ?int + { + return $this->selected_node; + } + + public function getSelectedLink(): ?string + { + return $this->selected_link; + } + + /** + * Selects a group, or a link, or nothing: the list of the groups is then shown. + */ + public function select(?int $node = null, ?string $link = null): void + { + $this->selected_node = $node; + $this->selected_link = $link; + } + + /** + * Centers the graph on the given group, if out of view. + */ + public function scrollTo(int $group): void + { + $this->scroll_to = $group; + } + + /** + * Applies the preferences of the user sent by the page, kept in the browser: `_orientation` + * (see setOrientation()) and `_drawing_strategy` (a drawn strategy). Unknown values are ignored. + * + * @param array $preferences + */ + public function setPreferences(array $preferences): void + { + $this->setOrientation((string) ($preferences['_orientation'] ?? '')); + $strategy = LinkStrategyEnum::tryFromDrawn((string) ($preferences['_drawing_strategy'] ?? '')); + if ($strategy instanceof LinkStrategyEnum) { + $this->setDrawingStrategy($strategy); + } + } + + /** + * Draws the graph horizontally or vertically: an unknown orientation is ignored. + */ + public function setOrientation(string $orientation): void + { + if (in_array($orientation, ['horizontal', 'vertical'], true)) { + $this->vertical = $orientation === 'vertical'; + } + } + + /** + * Strategy of the links drawn in the graph: the one chosen above it, basic by default. + */ + public function getDrawingStrategy(): LinkStrategyEnum + { + return $this->drawing_strategy; + } + + public function setDrawingStrategy(LinkStrategyEnum $strategy): void + { + $this->drawing_strategy = $strategy; + } + + /** + * Explains why two groups cannot be linked: the link would make a loop. + */ + public function refuseLink(int $from, int $to): void + { + $source = $this->tree->getNode($from); + $destination = $this->tree->getNode($to); + if ($source instanceof GroupNode && $destination instanceof GroupNode) { + $this->error = sprintf( + __('%1$s already escalates to %2$s, directly or not: linking them the other way would create a loop.', 'moreoptions'), + $destination->name, + $source->name, + ); + } + } + + /** + * Renders the editor (the part of the tab replaced after each change). + */ + public function display(): void + { + TemplateRenderer::getInstance()->display( + '@moreoptions/components/group_link/editor.html.twig', + $this->getTemplateVariables(), + ); + } + + /** + * Variables of the editor template (see templates/components/group_link/editor.html.twig). + * + * @return array + */ + public function getTemplateVariables(): array + { + return (new TreeView( + tree: $this->tree, + canedit: $this->canedit, + vertical: $this->vertical, + drawing_strategy: $this->drawing_strategy, + selected_node: $this->selected_node, + selected_link: $this->selected_link, + query: $this->query, + error: $this->error, + scroll_to: $this->scroll_to, + ))->getTemplateVariables() + ['state' => $this->getState()]; + } +} diff --git a/src/EscalationTree/TreeLayout.php b/src/EscalationTree/TreeLayout.php new file mode 100644 index 0000000..8865530 --- /dev/null +++ b/src/EscalationTree/TreeLayout.php @@ -0,0 +1,288 @@ + By group id + */ + private array $cells = []; + + /** + * @param array $levels Level of each group, see EscalationTree::getLevels() + */ + public function __construct(EscalationTree $tree, array $levels, private readonly bool $vertical) + { + $seen = []; + foreach ($tree->getNodes() as $node) { + $level = $levels[$node->id]; + $this->cells[$node->id] = ['level' => $level, 'index' => $seen[$level] = ($seen[$level] ?? 0) + 1]; + } + } + + /** + * Center of the group. + * + * @return array{x: int, y: int} + */ + public function getPosition(GroupNode $node): array + { + ['level' => $level, 'index' => $index] = $this->cells[$node->id]; + + return $this->point($this->levelStart($level) + intdiv($this->primarySize(), 2), $this->rowCenter($index)); + } + + /** + * Routes of the links: where each one starts and ends, and the points where it turns. + * + * A link to the next level only uses the corridor between both. A link going further, or back + * (in a loop), also follows a lane between the groups: from the corridor after its source to + * the corridor before its target. + * + * A link leaves its source from the middle of its output side (right, or bottom when + * vertical), and arrives at the middle of the input side of its target (left, or top). A link + * arriving at a group of the row a link to another group leaves from, across the same + * corridor, would overlap it there: it arrives a bit aside the middle of its target. + * + * @param array $links By key + * @return array}> By key + */ + public function getRoutes(array $links): array + { + // The corridors and lanes each link uses, then the track of each link in them + $paths = []; + $users = ['corridor' => [], 'lane' => []]; + foreach ($links as $key => $link) { + $from = $this->cells[$link->source]; + $to = $this->cells[$link->destination]; + // Corridors after the source level, and before the target one when not the same + $path = [ + 'link' => $link, + 'from' => $from, + 'to' => $to, + 'corridors' => [$from['level'], $from['level']], + 'lane' => null, + 'start' => $this->rowCenter($from['index']), + 'end' => $this->rowCenter($to['index']), + ]; + if ($to['level'] !== $from['level'] + 1) { + $path['corridors'][1] = $to['level'] - 1; + // Forwards: the lane closest to both groups; backwards: around, before the first group. + $path['lane'] = $to['level'] > $from['level'] + ? (int) round(($from['index'] + $to['index']) / 2 - 0.5) + : 0; + $users['lane'][$path['lane']][] = $key; + } + + foreach (array_unique($path['corridors']) as $corridor) { + $users['corridor'][$corridor][] = $key; + } + + $paths[$key] = $path; + } + + // Position of each link among the users of each corridor and lane + /** @var array{corridor: array>, lane: array>} $positions */ + $positions = ['corridor' => [], 'lane' => []]; + foreach ($users as $way => $by_way) { + foreach ($by_way as $index => $keys) { + $positions[$way][$index] = array_flip($keys); + } + } + + // Tracks in the corridors left (out) and reached (in); the links leaving each row of each corridor + $leaving = []; + foreach ($paths as $key => $path) { + $paths[$key]['tracks'] = []; + foreach ($path['corridors'] as $corridor) { + $paths[$key]['tracks'][] = $this->corridorCenter($corridor) + $this->track($key, $positions['corridor'][$corridor], self::SPACE_BETWEEN_LEVELS); + } + + $leaving[$path['corridors'][0]][$path['start']][] = $key; + } + + foreach ($paths as $key => $path) { + foreach ($leaving[$path['corridors'][1]][$path['end']] ?? [] as $other_key) { + $other = $paths[$other_key]; + if ( + $other['link']->source !== $path['link']->source + && $other['link']->destination !== $path['link']->destination + && $other['tracks'][0] > $path['tracks'][1] + ) { + $paths[$key]['end'] += self::ARRIVAL_SHIFT; + break; + } + } + } + + $routes = []; + foreach ($paths as $key => $path) { + ['start' => $start, 'end' => $end] = $path; + [$out, $in] = $path['tracks']; + + if ($path['lane'] !== null) { + $lane = $this->laneCenter($path['lane']) + $this->track($key, $positions['lane'][$path['lane']], self::SPACE_BETWEEN_GROUPS); + $points = [$this->point($out, $start), $this->point($out, $lane), $this->point($in, $lane), $this->point($in, $end)]; + } elseif ($start !== $end) { + $points = [$this->point($out, $start), $this->point($out, $end)]; + } else { + $points = []; + } + + // From the output side of the source group to the input side of the target one + $routes[$key] = [ + 'from' => $this->point($this->levelEnd($path['from']['level']), $start), + 'to' => $this->point($this->levelStart($path['to']['level']), $end), + 'points' => $points, + ]; + } + + return $routes; + } + + /** + * Offset of the track of a link in a corridor or a lane shared with other links: the tracks + * are spread around its center, within its width. + * + * @param array $users Positions of the links using it, by key + */ + private function track(string $key, array $users, int $width): int + { + $count = count($users); + $spacing = $count > 1 ? min(self::TRACK_SPACING, intdiv($width - 16, $count - 1)) : 0; + + return (int) round(($users[$key] - ($count - 1) / 2) * $spacing); + } + + /** Start of a level on the primary axis */ + private function levelStart(int $level): int + { + return 50 + ($level - 1) * $this->levelGap(); + } + + /** End of a level on the primary axis */ + private function levelEnd(int $level): int + { + return $this->levelStart($level) + $this->primarySize(); + } + + /** Start of a group of a level on the secondary axis, by its index in the level */ + private function indexStart(int $index): int + { + return 60 + ($index - 1) * $this->indexGap(); + } + + /** Middle of the groups of an index (in their level), on the secondary axis */ + private function rowCenter(int $index): int + { + return $this->indexStart($index) + intdiv($this->secondarySize(), 2); + } + + /** Middle of the corridor after a level (0: before the first one), on the primary axis */ + private function corridorCenter(int $level): int + { + return $this->levelEnd($level) + intdiv(self::SPACE_BETWEEN_LEVELS, 2); + } + + /** Middle of the lane after a group of a level (0: before the first one), on the secondary axis */ + private function laneCenter(int $index): int + { + return $this->indexStart($index) + $this->secondarySize() + intdiv(self::SPACE_BETWEEN_GROUPS, 2); + } + + /** Distance from a level to the next one, on the primary axis */ + private function levelGap(): int + { + return $this->primarySize() + self::SPACE_BETWEEN_LEVELS; + } + + /** Distance from a group of a level to the next one, on the secondary axis */ + private function indexGap(): int + { + return $this->secondarySize() + self::SPACE_BETWEEN_GROUPS; + } + + /** Size of a group on the primary axis */ + private function primarySize(): int + { + return $this->vertical ? self::NODE_HEIGHT : self::NODE_WIDTH; + } + + /** Size of a group on the secondary axis */ + private function secondarySize(): int + { + return $this->vertical ? self::NODE_WIDTH : self::NODE_HEIGHT; + } + + /** + * @return array{x: int, y: int} + */ + private function point(int $primary, int $secondary): array + { + return $this->vertical ? ['x' => $secondary, 'y' => $primary] : ['x' => $primary, 'y' => $secondary]; + } +} diff --git a/src/EscalationTree/TreeView.php b/src/EscalationTree/TreeView.php new file mode 100644 index 0000000..2e56f65 --- /dev/null +++ b/src/EscalationTree/TreeView.php @@ -0,0 +1,289 @@ + Level of each group + */ + private array $levels; + + public function __construct( + private EscalationTree $tree, + private bool $canedit, + private bool $vertical, + private LinkStrategyEnum $drawing_strategy, + private ?int $selected_node, + private ?string $selected_link, + private string $query, + private ?string $error, + private ?int $scroll_to, + ) { + $this->levels = $tree->getLevels(); + } + + /** + * @return array + */ + public function getTemplateVariables(): array + { + return [ + 'entities_id' => $this->tree->entities_id, + 'entity_name' => Dropdown::getDropdownName(Entity::getTable(), $this->tree->entities_id), + 'canedit' => $this->canedit, + 'can_reset' => $this->canedit && $this->tree->canReset(), + // A child entity gets back the links of its parent entities when reset + 'entity_is_root' => $this->tree->entities_id === 0, + 'orientation' => $this->vertical ? 'vertical' : 'horizontal', + 'drawing_strategy' => $this->drawing_strategy->value, + 'elements' => $this->getGraphElements(), + 'selected_node' => $this->describeSelectedNode(), + 'selected_link' => $this->describeSelectedLink(), + 'placed' => $this->getPlacedGroups(), + 'pool' => $this->getPool(), + 'query' => $this->query, + 'error' => $this->error, + 'has_selection' => $this->selected_node !== null || $this->selected_link !== null, + 'scroll_to' => $this->scroll_to, + 'strategies' => array_map($this->describeStrategy(...), LinkStrategyEnum::getDrawnCases()), + ]; + } + + /** + * A strategy a link can have, for the toolbar, the legend and the panels. + * + * @return array{value: string, label: string, description: string, color: string|null, replicable: bool} + */ + private function describeStrategy(LinkStrategyEnum $strategy): array + { + return [ + 'value' => $strategy->value, + 'label' => $strategy->getStrategy()->getLabel(), + 'description' => $strategy->getStrategy()->getDescription(), + 'color' => $strategy->getStrategy()->getColor(), + 'replicable' => $strategy->getStrategy()->appliesToSubEntities(), + ]; + } + + /** + * Elements of the graph, for Cytoscape.js (see public/js/escalation_graph.js): positions are + * the centers of the groups, links follow routes never crossing a group, colors are CSS colors + * resolved in the page. + * + * @return array{nodes: list>, edges: list>} + */ + private function getGraphElements(): array + { + $layout = new TreeLayout($this->tree, $this->levels, $this->vertical); + + $nodes = []; + $centers = []; + foreach ($this->tree->getNodes() as $node) { + $centers[$node->id] = $layout->getPosition($node); + $nodes[] = [ + // The level is shown as a badge on the group + 'data' => ['id' => (string) $node->id, 'label' => $node->name, 'level' => $this->levels[$node->id]], + 'position' => $centers[$node->id], + 'classes' => $this->classes(['selected' => $this->selected_node === $node->id]), + ]; + } + + $edges = []; + $routes = $layout->getRoutes($this->tree->getLinks()); + foreach ($this->tree->getLinks() as $key => $link) { + $edges[] = [ + 'data' => [ + 'id' => $key, + 'source' => (string) $link->source, + 'target' => (string) $link->destination, + // Points where it turns, around the groups, and where it starts and ends + // from the center of its groups (see TreeLayout::getRoutes()) + 'route' => $routes[$key], + 'endpoints' => [ + 'source' => $this->offset($routes[$key]['from'], $centers[$link->source]), + 'target' => $this->offset($routes[$key]['to'], $centers[$link->destination]), + ], + 'replicated' => $link->isReplicated(), + // The context menu only offers the other strategies + 'type' => $link->strategy->value, + 'color' => $link->strategy->getStrategy()->getColor() ?? 'currentColor', + ], + 'classes' => $this->classes(['selected' => $this->selected_link === $key]), + ]; + } + + return ['nodes' => $nodes, 'edges' => $edges]; + } + + /** + * The selected group, for its panel: all its links, to the groups it escalates to and from + * the ones escalating to it, and the groups its link forms offer. + * + * @return array|null + */ + private function describeSelectedNode(): ?array + { + $node = $this->selected_node !== null ? $this->tree->getNode($this->selected_node) : null; + if (!$node instanceof GroupNode) { + return null; + } + + $panel = [ + 'id' => $node->id, + 'name' => $node->name, + 'level' => $this->levels[$node->id], + ]; + foreach (EscalationLink::DIRECTIONS as $direction) { + $panel[$direction] = array_values(array_map($this->describeLink(...), $this->tree->getLinksOf($node->id, $direction))); + $panel['candidates_' . $direction] = $this->tree->getLinkCandidates($node->id, $direction); + } + + return $panel; + } + + /** + * The selected link, for its panel. + * + * @return array|null + */ + private function describeSelectedLink(): ?array + { + $link = $this->selected_link !== null ? $this->tree->getLink($this->selected_link) : null; + + return $link instanceof EscalationLink ? $this->describeLink($link) : null; + } + + /** + * A link, as shown in the panels: its groups, its strategy, the entity it is replicated from. + * + * @return array + */ + private function describeLink(EscalationLink $link): array + { + return [ + 'key' => $link->getKey(), + 'from_id' => $link->source, + 'from_name' => $this->tree->getNode($link->source)?->name, + 'to_id' => $link->destination, + 'to_name' => $this->tree->getNode($link->destination)?->name, + 'type' => $link->strategy->value, + 'label' => $link->strategy->getStrategy()->getLabel(), + 'color' => $link->strategy->getStrategy()->getColor(), + 'replicated' => $link->isReplicated(), + 'entity_name' => $link->origin ?? '', + ]; + } + + /** + * The groups of the graph, for the list of the side panel: they can be selected from it with + * the keyboard. By level, then by name. + * + * @return list + */ + private function getPlacedGroups(): array + { + $placed = []; + foreach ($this->tree->getNodes() as $node) { + $placed[] = [ + 'id' => $node->id, + 'name' => $node->name, + 'level' => $this->levels[$node->id], + 'matches' => $this->matches($node->name), + ]; + } + + usort($placed, $this->compareByLevelAndName(...)); + + return $placed; + } + + /** + * @param array{level: int, name: string} $a + * @param array{level: int, name: string} $b + */ + private function compareByLevelAndName(array $a, array $b): int + { + return [$a['level'], $a['name']] <=> [$b['level'], $b['name']]; + } + + /** + * The groups that can still be placed, for the list of the side panel. + * + * @return list + */ + private function getPool(): array + { + $pool = []; + foreach ($this->tree->getUnplacedGroups() as $id => $name) { + $pool[] = ['id' => $id, 'name' => $name, 'matches' => $this->matches($name)]; + } + + return $pool; + } + + /** Whether a group matches the search of the side panel */ + private function matches(string $name): bool + { + return $this->query === '' || mb_stripos($name, $this->query) !== false; + } + + /** + * Position of a point from the center of a group, as Cytoscape wants the ends of a link. + * + * @param array{x: int, y: int} $point + * @param array{x: int, y: int} $center + */ + private function offset(array $point, array $center): string + { + return sprintf('%dpx %dpx', $point['x'] - $center['x'], $point['y'] - $center['y']); + } + + /** + * @param array $classes + */ + private function classes(array $classes): string + { + return implode(' ', array_keys(array_filter($classes))); + } +} diff --git a/src/Group_Link.php b/src/Group_Link.php index 025c65f..9172dd0 100644 --- a/src/Group_Link.php +++ b/src/Group_Link.php @@ -35,17 +35,29 @@ namespace GlpiPlugin\Moreoptions; use CommonDBRelation; -use CommonGLPI; +use CommonDBTM; use DBConnection; use DBmysql; -use Glpi\Application\View\TemplateRenderer; +use Dropdown; +use Entity; +use GlpiPlugin\Moreoptions\EscalationTree\EscalationGraph; +use GlpiPlugin\Moreoptions\EscalationTree\EscalationLink; +use GlpiPlugin\Moreoptions\EscalationTree\TreeEditor; use GlpiPlugin\Moreoptions\LinkStrategy\LinkStrategyEnum; use Group; use Migration; +use Session; + +use function Safe\filemtime; /** - * Escalation hierarchy between groups: the destination groups a source group can escalate to. - * Managed from the "Escalation" tab of the source group. + * Links of the escalation hierarchy between groups: the destination groups a source group can + * escalate to, in an entity. Edited as a tree (see EscalationTree\TreeEditor) from the + * "Escalate" tab of the configuration of the entity (see Config::showForEntity()). + * + * The links are only changed from this tab, which checks the rights and keeps the tree + * consistent (no loop, entity of the links): never directly, from the generic form, list, + * massive actions or API of GLPI, which only check the rights on the groups. */ class Group_Link extends CommonDBRelation { @@ -67,49 +79,327 @@ public static function getIcon(): string return 'ti ti-escalator-up'; } - public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0): string + public static function canCreate(): bool + { + return false; + } + + public static function canView(): bool + { + return false; + } + + public static function canUpdate(): bool + { + return false; + } + + public static function canDelete(): bool + { + return false; + } + + public static function canPurge(): bool + { + return false; + } + + public function canCreateItem(): bool + { + return false; + } + + public function canViewItem(): bool + { + return false; + } + + public function canUpdateItem(): bool { - if (!$item instanceof Group || $item->isNewItem()) { - return ''; + return false; + } + + public function canDeleteItem(): bool + { + return false; + } + + public function canPurgeItem(): bool + { + return false; + } + + /** + * Variables of the editor of the escalation tree of the given entity (see + * templates/group_link.html.twig), shown in the "Escalate" tab of its configuration (see + * Config::showForEntity()). + * + * @return array + */ + public static function getEditorVariables(Entity $entity): array + { + return TreeEditor::fromDatabase($entity->getID())->getTemplateVariables() + [ + // Changes with the script, for the browser not to keep an outdated one in its cache. + 'script_version' => PLUGIN_MOREOPTIONS_VERSION . '-' . filemtime(dirname(__DIR__) . '/public/js/escalation_graph.js'), + ]; + } + + /** + * Groups a ticket can be assigned to in the given entity: the groups that can be linked there + * (see isAssignableIn()). + * + * @return array Names, by group id + */ + public static function getGroupsForEntity(int $entities_id): array + { + $parents = self::getParentEntities($entities_id); + $groups = (new Group())->find(['is_assign' => 1, 'entities_id' => [...$parents, $entities_id]], 'completename'); + $scopes = self::toScopes($groups); + $parents = array_flip($parents); + + $names = []; + foreach ($groups as $id => $group) { + if (self::isAssignableIn($id, $scopes, $entities_id, $parents)) { + $names[$id] = (string) $group['completename']; + } } - return self::createTabEntry(self::getTypeName(), 0, $item::class, self::getIcon()); + return $names; } - public static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $withtemplate = 0): bool + /** + * Names of the given groups, as the user may see them: the name of a group of an entity the + * user has no access to is hidden. + * + * @param list $ids + * @return array By group id + */ + public static function getGroupNames(array $ids): array { - if ($item instanceof Group) { - self::showForGroup($item); + $names = []; + foreach ($ids as $id) { + $names[$id] = ['name' => sprintf(__('Hidden group #%d', 'moreoptions'), $id), 'visible' => false]; + } + + $groups = $ids !== [] ? (new Group())->find(['id' => $ids]) : []; + foreach ($groups as $id => $group) { + if (Session::haveAccessToEntity((int) $group['entities_id'], (bool) $group['is_recursive'])) { + $names[$id] = ['name' => (string) $group['completename'], 'visible' => true]; + } } - return true; + return $names; } /** - * Renders the "Escalation" tab of the given group. + * Name of the given entity, as the user may see it: hidden for an entity the user has no access to. */ - public static function showForGroup(Group $group): void + public static function getEntityName(int $entities_id): string { - TemplateRenderer::getInstance()->display('@moreoptions/group_link.html.twig'); + return Session::haveAccessToEntity($entities_id) + ? Dropdown::getDropdownName(Entity::getTable(), $entities_id) + : sprintf(__('Hidden entity #%d', 'moreoptions'), $entities_id); } /** - * The links applying in the given entity. For each pair of groups, the link of the closest - * entity wins, from the given entity up to the root entity: a LinkStrategyEnum::BASIC link - * only applies in its own entity, a LinkStrategyEnum::INHERITED one also in its sub-entities, - * and a LinkStrategyEnum::NONE one removes the inherited link from its entity and its - * sub-entities. + * The links applying in the given entity, with the entity each one is stored in. For each pair + * of groups, the link applying is searched from the entity up to the root entity (see + * EscalationLink::resolve()). A link only applies between groups that can be assigned in the + * entity (see isAssignableIn()). * - * @return array> + * @return list */ public static function getLinksForEntity(int $entities_id): array { - return []; + return self::getApplyingLinks($entities_id, true); } - public function getLinkStrategy(): LinkStrategyEnum + /** + * Groups of the next level of the escalation, from the given group in the given entity: the + * groups it escalates to directly, through the links applying in the entity (see + * getLinksForEntity()). Whatever the rights of the user: for the escalation itself. + * + * @return list Ids of the groups, in ascending order + */ + public static function getNextLevelGroups(int $groups_id, int $entities_id): array + { + return self::getNextLevelGroupsOf([$groups_id], $entities_id); + } + + /** + * Groups of the next level of the escalation, from any of the given groups in the given entity + * (see getNextLevelGroups()). + * + * @param list $groups_ids + * @return list Ids of the groups, in ascending order + */ + public static function getNextLevelGroupsOf(array $groups_ids, int $entities_id): array + { + $graph = EscalationGraph::fromLinks([], self::getLinksForEntity($entities_id)); + + $destinations = []; + foreach ($groups_ids as $groups_id) { + array_push($destinations, ...$graph->getChildren($groups_id)); + } + + $destinations = array_values(array_unique($destinations)); + sort($destinations); + + return $destinations; + } + + /** + * The links the parent entities pass down to the given entity, ignoring its own links: the + * links it would get if it had none (see getLinksForEntity()). + * + * @return list + */ + public static function getInheritedLinks(int $entities_id): array + { + return self::getApplyingLinks($entities_id, false); + } + + /** + * Stored links of the given entity, in creation order. + * + * @return list> + */ + public static function getRowsOfEntity(int $entities_id): array + { + return array_values((new self())->find(['entities_id' => $entities_id], 'id')); + } + + /** + * Version of the stored links of an entity, changing each time they are saved: a draft made + * from an older version would overwrite the links saved since. + * + * @param list> $rows See getRowsOfEntity() + */ + public static function getVersion(array $rows): string { - return LinkStrategyEnum::tryFrom((string) ($this->fields['link_type'] ?? '')) ?? LinkStrategyEnum::getDefault(); + $fields = []; + foreach ($rows as $row) { + $fields[] = [$row['id'], ...array_values(EscalationLink::fromRow($row)->toRow())]; + } + + return md5(serialize($fields)); + } + + /** + * Replaces the links of the given entity by the given ones, as drawn in the "Escalate" tab of its configuration. + * + * Only the links between groups that can be linked in the entity, and the ones inherited from + * its parent entities, are managed: the given ones between other groups are ignored, and the + * stored ones between other groups (a group that cannot be assigned any more, for instance) + * are left as they are, unless all are replaced. + * + * The LinkStrategyEnum::NONE links of the entity are kept unless the given links define the + * same pair of groups, or the inherited link they remove applies again in the entity. With + * $replace_all, all the links of the entity are replaced (the graph was reset). + * + * The groups that can be linked in the entity, and the inherited links, are read from the + * database unless given (as already known by an EscalationTree, for instance). + * + * @param list $links Links of the entity, LinkStrategyEnum::NONE ones included + * @param list $applying Keys (see EscalationLink::key()) of the inherited links applying in the entity + * @param array|null $groups See getGroupsForEntity() + * @param list|null $inherited Keys of the links inherited from the parent entities, see getInheritedLinks() + */ + public static function saveLinksForEntity( + int $entities_id, + array $links, + array $applying = [], + bool $replace_all = false, + ?array $groups = null, + ?array $inherited = null, + ): void { + $groups ??= self::getGroupsForEntity($entities_id); + if ($inherited === null) { + $inherited = []; + foreach (self::getInheritedLinks($entities_id) as $link) { + $inherited[] = $link->getKey(); + } + } + + $inherited = array_flip($inherited); + $applying = array_flip($applying); + + $wanted = []; + foreach ($links as $link) { + if ($link->source !== $link->destination && self::manages($link, $groups, $inherited)) { + $wanted[$link->getKey()] = $link; + } + } + + $group_link = new self(); + foreach (self::getRowsOfEntity($entities_id) as $row) { + $stored = EscalationLink::fromRow($row); + $key = $stored->getKey(); + if (!$replace_all && !self::manages($stored, $groups, $inherited)) { + continue; + } + + if (!isset($wanted[$key])) { + if ($replace_all || $stored->strategy !== LinkStrategyEnum::NONE || isset($applying[$key])) { + $group_link->delete(['id' => $row['id'], '_no_message' => true]); + } + + continue; + } + + if ($stored->strategy !== $wanted[$key]->strategy) { + $group_link->update(['id' => $row['id'], 'link_type' => $wanted[$key]->strategy->value, '_no_message' => true]); + } + + unset($wanted[$key]); + } + + foreach ($wanted as $link) { + $group_link->add($link->toRow() + ['entities_id' => $entities_id, '_no_message' => true]); + } + } + + /** + * A loop in the links applying in the given entity or in one of its sub-entities: groups + * escalating, directly or not, to themselves. Links of different entities can make one: a + * link inherited from a parent entity, the other way of a link of a sub-entity. + * + * @return array{entities_id: int, groups: list}|null The first entity with a loop, and the groups of the loop, in order + * + * @phpstan-impure Read from the database + */ + public static function findLoop(int $entities_id): ?array + { + $sons = array_map(intval(...), array_values(getSonsOf(Entity::getTable(), $entities_id))); + $by_pair = self::getLinksByPair([...self::getParentEntities($entities_id), ...$sons]); + $scopes = self::getGroupScopes($by_pair); + foreach ($sons as $entity) { + $links = self::resolveLinks($by_pair, $scopes, $entity, self::getParentEntities($entity), true); + $loop = EscalationGraph::fromLinks([], $links)->findCycle(); + if ($loop !== null) { + return ['entities_id' => $entity, 'groups' => $loop]; + } + } + + return null; + } + + /** + * Deletes the links of a purged group (see setup.php). + */ + public static function cleanForGroup(CommonDBTM $group): void + { + (new self())->deleteByCriteria([ + 'OR' => ['groups_id_source' => $group->getID(), 'groups_id_destination' => $group->getID()], + ], true); + } + + /** + * Deletes the links of a purged entity (see setup.php). + */ + public static function cleanForEntity(CommonDBTM $entity): void + { + (new self())->deleteByCriteria(['entities_id' => $entity->getID()], true); } public static function install(Migration $migration): void @@ -147,4 +437,161 @@ public static function uninstall(Migration $migration): void { $migration->dropTable(self::getTable()); } + + /** + * The links applying in the given entity (see getLinksForEntity()), or only the ones its parent + * entities pass down to it (see getInheritedLinks()). + * + * @return list + */ + private static function getApplyingLinks(int $entities_id, bool $with_own): array + { + $parents = self::getParentEntities($entities_id); + $by_pair = self::getLinksByPair($with_own ? [...$parents, $entities_id] : $parents); + + return self::resolveLinks($by_pair, self::getGroupScopes($by_pair), $entities_id, $parents, $with_own); + } + + /** + * Parent entities of the given entity, up to the root entity. + * + * @return list + */ + private static function getParentEntities(int $entities_id): array + { + // getAncestorsOf() gives the root entity as its own ancestor. + return array_values(array_diff( + array_map(intval(...), array_values(getAncestorsOf(Entity::getTable(), $entities_id))), + [$entities_id], + )); + } + + /** + * Stored links of the given entities, by pair of groups (see EscalationLink::key()), from the + * deepest entity: as EscalationLink::resolve() wants them. + * + * @param list $entities + * @return array> + */ + private static function getLinksByPair(array $entities): array + { + if ($entities === []) { + return []; + } + + $by_entity = []; + foreach ((new self())->find(['entities_id' => $entities]) as $row) { + $by_entity[(int) $row['entities_id']][] = EscalationLink::fromRow($row); + } + + // The entities from the deepest one, so that the links of each pair are added in this order + $by_pair = []; + foreach (array_keys((new Entity())->find(['id' => $entities], 'level DESC')) as $entity) { + foreach ($by_entity[$entity] ?? [] as $link) { + $by_pair[$link->getKey()][] = $link; + } + } + + return $by_pair; + } + + /** + * Where the groups of the given links can be assigned (see isAssignableIn()): the groups that + * cannot be assigned are left out. + * + * @param array> $by_pair See getLinksByPair() + * @return array By group id + */ + private static function getGroupScopes(array $by_pair): array + { + $ids = []; + foreach ($by_pair as $links) { + $ids[$links[0]->source] = true; + $ids[$links[0]->destination] = true; + } + + return self::toScopes($ids !== [] ? (new Group())->find(['id' => array_keys($ids), 'is_assign' => 1]) : []); + } + + /** + * Where the given groups can be assigned (see isAssignableIn()). + * + * @param array> $groups Rows of the groups, by id + * @return array By group id + */ + private static function toScopes(array $groups): array + { + $scopes = []; + foreach ($groups as $id => $group) { + $scopes[$id] = [ + 'entities_id' => (int) $group['entities_id'], + 'is_recursive' => (bool) $group['is_recursive'], + ]; + } + + return $scopes; + } + + /** + * Whether a link is managed by saveLinksForEntity(): between groups that can be linked in the + * entity, or inherited from its parent entities. + * + * @param array $groups See getGroupsForEntity() + * @param array $inherited Keys of the inherited links, as keys + */ + private static function manages(EscalationLink $link, array $groups, array $inherited): bool + { + return isset($groups[$link->source], $groups[$link->destination]) || isset($inherited[$link->getKey()]); + } + + /** + * The links applying in an entity (see getLinksForEntity()), among the given ones. + * + * @param array> $by_pair See getLinksByPair() + * @param array $scopes See getGroupScopes() + * @param list $parents Parent entities of the entity + * @param bool $with_own Whether the links of the entity itself are searched too: if not, the links the parent entities pass down to it + * @return list + */ + private static function resolveLinks(array $by_pair, array $scopes, int $entities_id, array $parents, bool $with_own): array + { + // Entities whose links are searched + $entities = array_flip($with_own ? [...$parents, $entities_id] : $parents); + $parents = array_flip($parents); + + $applying = []; + foreach ($by_pair as $links) { + if ( + !self::isAssignableIn($links[0]->source, $scopes, $entities_id, $parents) + || !self::isAssignableIn($links[0]->destination, $scopes, $entities_id, $parents) + ) { + continue; + } + + $link = EscalationLink::resolve($links, $entities_id, $entities); + if ($link instanceof EscalationLink) { + $applying[] = $link; + } + } + + return $applying; + } + + /** + * Whether the group can be assigned in the given entity: assignable (not a purged one), of the + * entity, or of a parent entity and recursive. + * + * @param array $scopes See toScopes() + * @param array $parents Parent entities of the entity, as keys + */ + private static function isAssignableIn(int $group, array $scopes, int $entities_id, array $parents): bool + { + if (!isset($scopes[$group])) { + return false; + } + + ['entities_id' => $group_entity, 'is_recursive' => $is_recursive] = $scopes[$group]; + + return $group_entity === $entities_id || ($is_recursive && isset($parents[$group_entity])); + } } diff --git a/src/LinkStrategy/AbstractLinkStrategy.php b/src/LinkStrategy/AbstractLinkStrategy.php index 487bec7..f6f8092 100644 --- a/src/LinkStrategy/AbstractLinkStrategy.php +++ b/src/LinkStrategy/AbstractLinkStrategy.php @@ -37,13 +37,20 @@ abstract class AbstractLinkStrategy { /** - * Get the display options of the arrow representing the link between the two groups - * - * @return array + * Get the CSS color of the arrow representing the link in the escalation graph, null for a + * link that is not drawn. A link replicated from a parent entity is drawn dashed. */ - public function getArrowOptions(): array + public function getColor(): ?string { - return []; + return null; + } + + /** + * Whether the link is drawn in the escalation graph, and can be chosen for a link there + */ + public function isDrawn(): bool + { + return $this->getColor() !== null; } /** @@ -68,7 +75,11 @@ public function blocksInheritance(): bool abstract public function getLabel(): string; /** - * Get the icon of the link strategy + * Get the description of the link strategy, shown in the escalation graph: where the link + * applies. Empty for a link that is not drawn. */ - abstract public function getIcon(): string; + public function getDescription(): string + { + return ''; + } } diff --git a/src/LinkStrategy/BasicLink.php b/src/LinkStrategy/BasicLink.php index a0d2f75..aa750a9 100644 --- a/src/LinkStrategy/BasicLink.php +++ b/src/LinkStrategy/BasicLink.php @@ -44,8 +44,13 @@ public function getLabel(): string return __('Basic', 'moreoptions'); } - public function getIcon(): string + public function getDescription(): string { - return 'ti ti-arrow-right'; + return __('Limited to the entity where it was created', 'moreoptions'); + } + + public function getColor(): string + { + return 'var(--mo-gl-basic)'; } } diff --git a/src/LinkStrategy/InheritedLink.php b/src/LinkStrategy/InheritedLink.php index 711082c..576fff7 100644 --- a/src/LinkStrategy/InheritedLink.php +++ b/src/LinkStrategy/InheritedLink.php @@ -49,8 +49,13 @@ public function getLabel(): string return __('Inherited', 'moreoptions'); } - public function getIcon(): string + public function getDescription(): string { - return 'ti ti-sitemap'; + return __('Also replicated in the child entities', 'moreoptions'); + } + + public function getColor(): string + { + return 'var(--mo-gl-inherited)'; } } diff --git a/src/LinkStrategy/LinkStrategyEnum.php b/src/LinkStrategy/LinkStrategyEnum.php index 1268bd0..dff9320 100644 --- a/src/LinkStrategy/LinkStrategyEnum.php +++ b/src/LinkStrategy/LinkStrategyEnum.php @@ -52,6 +52,24 @@ public function getStrategy(): AbstractLinkStrategy }; } + /** + * Get the strategy of a stored value, the default one for an unknown value + */ + public static function fromValue(mixed $value): self + { + return (is_string($value) ? self::tryFrom($value) : null) ?? self::getDefault(); + } + + /** + * Get the strategy of a value sent by the escalation graph: null unless a drawn one + */ + public static function tryFromDrawn(string $value): ?self + { + $strategy = self::tryFrom($value); + + return $strategy?->getStrategy()->isDrawn() ? $strategy : null; + } + /** * Get the default strategy */ @@ -61,18 +79,19 @@ public static function getDefault(): self } /** - * Get all available link strategies + * Get the strategies of the links drawn in the escalation graph * - * @return array + * @return list */ - public static function getAvailableStrategies(): array + public static function getDrawnCases(): array { - $strategies = []; - - foreach (LinkStrategyEnum::cases() as $case) { - $strategies[$case->value] = $case->getStrategy(); + $cases = []; + foreach (self::cases() as $case) { + if ($case->getStrategy()->isDrawn()) { + $cases[] = $case; + } } - return $strategies; + return $cases; } } diff --git a/src/LinkStrategy/NoneLink.php b/src/LinkStrategy/NoneLink.php index 50bdf08..95e12fe 100644 --- a/src/LinkStrategy/NoneLink.php +++ b/src/LinkStrategy/NoneLink.php @@ -48,9 +48,4 @@ public function getLabel(): string { return __('None', 'moreoptions'); } - - public function getIcon(): string - { - return 'ti ti-unlink'; - } } diff --git a/templates/components/group_link/editor.html.twig b/templates/components/group_link/editor.html.twig new file mode 100644 index 0000000..2f90215 --- /dev/null +++ b/templates/components/group_link/editor.html.twig @@ -0,0 +1,163 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # @link https://gitlab.teclib.com/glpi-network/moreoptions/ + # ------------------------------------------------------------------------- + #} + +{# + # Escalation tree editor (see EscalationTree\TreeEditor): the part of the "Escalate" tab of the + # entity configuration rendered again by ajax/group_link.php after each change. The form holds the + # draft being edited, sent back with each change. The graph is drawn by Cytoscape.js, as the + # impact analysis of GLPI, from the elements given in `data-mo-elements` (see + # public/js/escalation_graph.js). + #} +{% import 'components/alerts_macros.html.twig' as alerts %} +{% import '@moreoptions/components/group_link/macros.html.twig' as macros %} + +
+ + + + {# One toolbar, with icons: what each button does is in its tooltip, the gestures in the help. #} +
+ {% if canedit %} + {# Undone and redone in the page (see public/js/escalation_graph.js), which enables them #} +
+ + +
+ {% endif %} + + {# View of the graph #} + {{ macros.choices('orientation', 'orientation', [ + {'value': 'horizontal', 'label': __('Horizontal', 'moreoptions'), 'title': __('Horizontal', 'moreoptions'), 'icon': 'ti-layout-distribute-vertical'}, + {'value': 'vertical', 'label': __('Vertical', 'moreoptions'), 'title': __('Vertical', 'moreoptions'), 'icon': 'ti-layout-distribute-horizontal'}, + ], orientation, entities_id, __('Orientation', 'moreoptions')) }} + + {% if canedit %} + {# Applied to the draft, as the other changes: saved with the "Save" button. #} + {% set reset_label = entity_is_root ? __('Remove all the links', 'moreoptions') : __('Remove the links of this entity, and restore the ones inherited from the parent entities', 'moreoptions') %} + {% set reset_confirm = entity_is_root ? __('Remove all the links of the graph?', 'moreoptions') : __('Remove all the links of this entity? The links inherited from the parent entities are restored.', 'moreoptions') %} + + {% endif %} + + {{ include('@moreoptions/components/group_link/legend.html.twig', { + 'strategies': strategies, + 'drawing_strategy': drawing_strategy, + 'canedit': canedit, + 'uid': entities_id, + }, with_context = false) }} + +
+ {# The gestures of the graph, instead of always written on it #} + {% set gestures = (canedit ? [ + __('Drag a group onto another group to link them.', 'moreoptions'), + __('Drag a group of the list onto the graph to place it.', 'moreoptions'), + __('Right-click a group or a link for more actions.', 'moreoptions'), + __('Delete: removes the selection. Ctrl+Z / Ctrl+Y: undo / redo.', 'moreoptions'), + ] : [])|merge([ + __('Escape: closes the selection.', 'moreoptions'), + __('Drag the background to move around, scroll to zoom.', 'moreoptions'), + __('Dashed links are inherited from a parent entity.', 'moreoptions'), + ]) %} + {# A link, not a button: Safari does not focus a clicked button, the "focus" trigger needs it. #} +
  • " ~ gestures|map(gesture => gesture|e)|join('
  • ') ~ '
  • ')|e('html_attr') }}"> + +
    + + + + {{ entity_name }} + +
    +
    + +
    +
    + + +
    + {% if error is not null %} +
    + {{ alerts.alert_danger('', [error]) }} +
    + {% endif %} + + {% if selected_node is not null %} + {{ include('@moreoptions/components/group_link/group_panel.html.twig', { + 'selected_node': selected_node, + 'strategies': strategies, + 'canedit': canedit, + }, with_context = false) }} + {% endif %} + + {% if selected_link is not null %} + {{ include('@moreoptions/components/group_link/link_panel.html.twig', { + 'selected_link': selected_link, + 'strategies': strategies, + 'canedit': canedit, + }, with_context = false) }} + {% endif %} + + {# The list of the groups gives way to the panel of the selected group or link. #} + {% if not has_selection %} + {{ include('@moreoptions/components/group_link/group_list.html.twig', { + 'placed': placed, + 'pool': pool, + 'query': query, + 'canedit': canedit, + }, with_context = false) }} + {% else %} +
    + {% endif %} + + +
    +
    + +
    diff --git a/templates/components/group_link/group_list.html.twig b/templates/components/group_link/group_list.html.twig new file mode 100644 index 0000000..2561581 --- /dev/null +++ b/templates/components/group_link/group_list.html.twig @@ -0,0 +1,124 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # @link https://gitlab.teclib.com/glpi-network/moreoptions/ + # ------------------------------------------------------------------------- + #} + +{# + # Groups of the side panel, with their search field: the groups of the graph, which can be + # selected from here with the keyboard, then the groups that can still be placed. The search is + # applied server side on each render, and in the page while typing (see public/js/escalation_graph.js). + # A group is placed by clicking it, or by dragging it onto the graph: each one is shown as a small + # card, as the groups of the graph, with a grip. + # + # @param placed See TreeView::getTemplateVariables() + # @param pool See TreeView::getTemplateVariables() + # @param query Current search + # @param canedit Whether groups can be placed + #} +{% import '@moreoptions/components/group_link/macros.html.twig' as macros %} + +{# + # Title of a section of the list, with its number of groups: folds and unfolds the section. The + # folded sections stay folded when the editor is rendered again (see public/js/escalation_graph.js). + #} +{% macro section(key, title, groups) %} + +{% endmacro %} + +{# What a section of the list says when none of its groups is shown (see the search in public/js/escalation_graph.js) #} +{% macro empty_hint(groups, empty) %} +
    group.matches) is not empty ? 'hidden' }}> + {{ groups is not empty ? __('No group matches the search.', 'moreoptions') : empty }} +
    +{% endmacro %} + +
    +

    + + {{ 'Group'|itemtype_name(get_plural_number()) }} +

    +
    +
    +
    + + +
    +
    + +
    +
    + {{ _self.section('placed', __('In the graph', 'moreoptions'), placed) }} +
    +
    + {% for group in placed %} + + {% endfor %} + {{ _self.empty_hint(placed, __('No group in the graph yet.', 'moreoptions')) }} +
    +
    +
    + + {% if canedit %} +
    + {{ _self.section('pool', __('To place', 'moreoptions'), pool) }} +
    +
    + {% for group in pool %} + + {% endfor %} + {{ _self.empty_hint(pool, __('All the groups are placed.', 'moreoptions')) }} +
    +
    +
    + {% endif %} +
    diff --git a/templates/components/group_link/group_panel.html.twig b/templates/components/group_link/group_panel.html.twig new file mode 100644 index 0000000..d5cc15f --- /dev/null +++ b/templates/components/group_link/group_panel.html.twig @@ -0,0 +1,156 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # @link https://gitlab.teclib.com/glpi-network/moreoptions/ + # ------------------------------------------------------------------------- + #} + +{# + # Side panel of the selected group (included by editor.html.twig): all its links, to the groups it + # escalates to and from the groups escalating to it. Clicking a link selects it, its cross deletes + # it. Below each list, a folded form adds a link to or from another group: only the groups it can + # be linked with are offered (see EscalationTree::getLinkCandidates()). + # + # @param selected_node See TreeView::getTemplateVariables() + # @param strategies Strategies a link can have + # @param canedit + #} +{% import '@moreoptions/components/group_link/macros.html.twig' as macros %} + +{% macro links(title, icon, empty, direction, add_label, selected_node, strategies, canedit) %} + {% import '@moreoptions/components/group_link/macros.html.twig' as macros %} + {% set links = selected_node[direction] %} + {% set candidates = selected_node['candidates_' ~ direction] %} + {% set form_id = 'mo-gl-add-' ~ direction ~ '-' ~ selected_node.id %} + +
    +
    + + {{ title }} +
    +
    + {{ links|length }} +
    +
    + +
    + {% for link in links %} +
    + + {% if canedit %} + + {% endif %} +
    + {% else %} +
    {{ empty }}
    + {% endfor %} + + {% if canedit and candidates is not empty %} +
    + +
    +
    + {% do call('Dropdown::showFromArray', ['_link_' ~ direction ~ '_group', candidates, { + 'display_emptychoice': true, + 'width': '100%', + 'aria_label': add_label, + }]) %} +
    +
    + {% do call('Dropdown::showFromArray', [ + '_link_' ~ direction ~ '_strategy', + strategies|reduce((choices, strategy) => choices|merge({(strategy.value): strategy.label}), {}), + {'value': (strategies|first).value, 'width': '100%', 'aria_label': __('Link type', 'moreoptions')}, + ]) %} +
    + {# The add button of GLPI #} + +
    +
    +
    +
    + {% endif %} +
    +{% endmacro %} + +
    +
    +

    + + {{ selected_node.name }} +

    +
    + {{ macros.level_badge(selected_node.level) }} +
    +
    + + {{ _self.links( + __('Escalates to', 'moreoptions'), + 'ti-arrow-narrow-right', + __('This group does not escalate to any group.', 'moreoptions'), + 'to', + __('Add a group to escalate to', 'moreoptions'), + selected_node, + strategies, + canedit, + ) }} + {{ _self.links( + __('Receives escalations from', 'moreoptions'), + 'ti-arrow-narrow-left', + __('No group escalates to it: it is a first level.', 'moreoptions'), + 'from', + __('Add a group escalating to it', 'moreoptions'), + selected_node, + strategies, + canedit, + ) }} + + {% if canedit %} + {# From the graph only: the GLPI group itself is not deleted. #} + {{ macros.delete_button('remove_node', __('Remove the group from the graph, with its links', 'moreoptions'), selected_node.id) }} + {% endif %} +
    diff --git a/templates/components/group_link/legend.html.twig b/templates/components/group_link/legend.html.twig new file mode 100644 index 0000000..f880766 --- /dev/null +++ b/templates/components/group_link/legend.html.twig @@ -0,0 +1,70 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # @link https://gitlab.teclib.com/glpi-network/moreoptions/ + # ------------------------------------------------------------------------- + #} + +{# + # Arrows of the graph, in its toolbar (included by editor.html.twig). With the right to edit the + # links, the arrows of the strategies are the choice of the strategy of the links drawn; then + # comes the dashed arrow of the links replicated from a parent entity. Each one is explained in + # its tooltip. + # + # @param strategies See TreeView::getTemplateVariables() + # @param drawing_strategy Strategy of the links drawn in the graph + # @param canedit + # @param uid Unique part of the ids of the fields + #} +{% import '@moreoptions/components/group_link/macros.html.twig' as macros %} + +
    + {% if canedit %} + {{ macros.choices( + 'drawing_strategy', + 'drawing_strategy', + strategies|map(strategy => strategy|merge({'title': __('Link to draw', 'moreoptions') ~ ': ' ~ strategy.label ~ '. ' ~ strategy.description})), + drawing_strategy, + uid, + __('Link to draw', 'moreoptions'), + ) }} + {% else %} + {% for strategy in strategies %} + + {{ macros.arrow(strategy.color) }} + {{ strategy.label }} + + {% endfor %} + {% endif %} + {% for strategy in strategies|filter(strategy => strategy.replicable) %} + {% set label = __('%s, replicated from a parent entity', 'moreoptions')|format(strategy.label) %} + + {{ macros.arrow(strategy.color, true) }} + + {% endfor %} +
    diff --git a/templates/components/group_link/link_panel.html.twig b/templates/components/group_link/link_panel.html.twig new file mode 100644 index 0000000..80d69cf --- /dev/null +++ b/templates/components/group_link/link_panel.html.twig @@ -0,0 +1,94 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # @link https://gitlab.teclib.com/glpi-network/moreoptions/ + # ------------------------------------------------------------------------- + #} + +{# + # Side panel of the selected link (included by editor.html.twig): its groups, from the one + # escalating to the one receiving the escalations (clicking one selects it), its strategy, and the + # button to delete it. A link replicated from a parent entity cannot be changed here, only removed + # from the entity. + # + # @param selected_link See TreeView::getTemplateVariables() + # @param strategies Strategies a link can have + # @param canedit + #} +{% import '@moreoptions/components/group_link/macros.html.twig' as macros %} + +
    +
    +

    {{ __('Link', 'moreoptions') }}

    +
    + +
    + {# From the group escalating to the group receiving the escalations #} +
    + {% for side in [ + {'id': selected_link.from_id, 'name': selected_link.from_name, 'hint': __('Escalates to the child group', 'moreoptions')}, + {'id': selected_link.to_id, 'name': selected_link.to_name, 'hint': __('Receives the escalations of the parent group', 'moreoptions')}, + ] %} + {% if not loop.first %} + {{ macros.arrow(selected_link.color, selected_link.replicated) }} + {% endif %} + + {% endfor %} +
    + + {% if selected_link.replicated %} + + {% else %} + {# The strategy, as the arrows of the toolbar #} + {{ macros.choices( + 'link_type', + 'set_link_type', + strategies|map(strategy => strategy|merge({'title': strategy.description})), + selected_link.type, + selected_link.key, + __('Link type', 'moreoptions'), + not canedit, + 'w-100', + ) }} + {% endif %} +
    + + {% if canedit %} + {{ macros.delete_button('delete_link', __('Delete the link', 'moreoptions')) }} + {% endif %} +
    diff --git a/templates/components/group_link/macros.html.twig b/templates/components/group_link/macros.html.twig new file mode 100644 index 0000000..4ca80ac --- /dev/null +++ b/templates/components/group_link/macros.html.twig @@ -0,0 +1,107 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # @link https://gitlab.teclib.com/glpi-network/moreoptions/ + # ------------------------------------------------------------------------- + #} + +{# + # Pieces of the escalation graph shared by its templates. + #} + +{# + # Arrow of a link strategy, as drawn in the graph (see AbstractLinkStrategy::getColor()): dashed + # for a link replicated from a parent entity. Used by the legend and the panels. + # + # @param color CSS color of the arrow + # @param replicated Whether the link is replicated from a parent entity + #} +{% macro arrow(color, replicated = false) %} + {% set width = 26 %} + +{% endmacro %} + +{# Level of a group in the tree (see EscalationTree::getLevels()) #} +{% macro level_badge(level) %} + {{ __('L%d', 'moreoptions')|format(level) }} +{% endmacro %} + +{# + # Choice among a few values, as toggle buttons: choosing one sends `action` with its value (see + # public/js/escalation_graph.js). + # + # @param name Name of the field + # @param action Action sent, see Action\TreeActionRegistry + # @param options List of `value`, `label`, `title` (tooltip), and either `icon` (shown alone) or `color` (of its arrow) + # @param checked Value chosen + # @param uid Unique part of the ids of the fields + # @param aria_label + # @param disabled + # @param classes Classes of the group of buttons + #} +{% macro choices(name, action, options, checked, uid, aria_label, disabled = false, classes = '') %} +
    + {% for option in options %} + {% set id = 'mo-gl-' ~ name ~ '-' ~ option.value ~ '-' ~ uid %} + + + {% endfor %} +
    +{% endmacro %} + +{# + # The delete button of GLPI, at the bottom of a side panel. + # + # @param action Action sent, see Action\TreeActionRegistry + # @param title What it deletes + # @param group Group it applies to, if any + #} +{% macro delete_button(action, title, group = null) %} + +{% endmacro %} diff --git a/templates/config.html.twig b/templates/config.html.twig index adeba3e..4a3fd7c 100644 --- a/templates/config.html.twig +++ b/templates/config.html.twig @@ -121,7 +121,20 @@ {% endif %}
    - {% if row.kind == 'status' %} + {% if row.kind == 'profiles' %} + {% if parent_badges[row.field] is defined %} +
    {{ parent_badges[row.field]|raw }}
    + {% endif %} +
    + {{ call('Dropdown::showFromArray', [row.field, profile_options, { + 'multiple': true, + 'values': profiles_values[row.field], + 'width': '320px', + 'disabled': not canedit, + 'display': false, + }])|raw }} +
    + {% elseif row.kind == 'status' %} {% if parent_badges[row.field] is defined %}
    {{ parent_badges[row.field]|raw }}
    {% endif %} @@ -154,6 +167,29 @@ {{ include('components/form/buttons.html.twig') }} + + {# + # Escalation tree of the entity, below the settings of the "Escalate filter" tab: outside of + # the form above, the editor having its own (see group_link.html.twig). On this tab, a single + # "Save" button, below the tree, saves both: the tree first, then the settings. + #} +
    +
    + + {{ __('Escalation tree', 'moreoptions') }} +
    + {{ include('@moreoptions/group_link.html.twig', escalation_tree, with_context = false) }} + + {% if canedit %} + {# Same bar as the buttons of the form (see components/form/buttons.html.twig) #} +
    + +
    + {% endif %} +
    + + diff --git a/templates/escalation_form.html.twig b/templates/escalation_form.html.twig index 16ecff8..65aa917 100644 --- a/templates/escalation_form.html.twig +++ b/templates/escalation_form.html.twig @@ -78,12 +78,20 @@ 'icon_label': true, 'required': true, 'entity': item.fields['entities_id'], - 'condition': {'is_assign': 1}, + 'condition': group_condition, 'rand': rand, 'aria_label': 'Group'|itemtype_name, 'used' : groups_used, } ) }} + {% if no_allowed_group %} +
    + +
    + {% endif %} {% set observer_lbl %}
    - diff --git a/templates/group_link.html.twig b/templates/group_link.html.twig index 8f000a6..47b1a39 100644 --- a/templates/group_link.html.twig +++ b/templates/group_link.html.twig @@ -26,5 +26,67 @@ # @copyright Copyright (C) 2025 by the MoreOptions plugin team. # @license MIT https://opensource.org/licenses/mit-license.php # @link https://github.com/pluginsGLPI/moreoptions + # @link https://gitlab.teclib.com/glpi-network/moreoptions/ # ------------------------------------------------------------------------- #} + +{# + # Editor of the escalation tree of an entity (see Group_Link::getEditorVariables() and + # EscalationTree\TreeEditor), driven by public/js/escalation_graph.js. + # The graph is drawn with Cytoscape.js, the library of the impact analysis of GLPI. + #} + + +{% set container_id = 'mo-group-link-' ~ random() %} +{% set config = { + 'url': path('/plugins/moreoptions/ajax/group_link.php'), + 'node': { + 'width': constant('GlpiPlugin\\Moreoptions\\EscalationTree\\TreeLayout::NODE_WIDTH'), + 'height': constant('GlpiPlugin\\Moreoptions\\EscalationTree\\TreeLayout::NODE_HEIGHT'), + }, + 'strategies': strategies, + 'strings': { + 'remove_node': __('Remove from the graph', 'moreoptions'), + 'delete_link': __('Delete the link', 'moreoptions'), + 'link_type': __('Link type: %s', 'moreoptions'), + 'error': __('The change could not be applied. Reload the page and try again.', 'moreoptions'), + 'level': __('L%d', 'moreoptions'), + 'load_error': __('The graph could not be loaded. Reload the page and try again.', 'moreoptions'), + }, +} %} + + + + + diff --git a/tests/EscalationTestCase.php b/tests/EscalationTestCase.php new file mode 100644 index 0000000..8194d31 --- /dev/null +++ b/tests/EscalationTestCase.php @@ -0,0 +1,142 @@ +getTestRootEntity(true); + $this->assertIsInt($root_id); + + return $root_id; + } + + protected function createChildEntity(string $name, int $parent): int + { + return $this->createItem(Entity::class, ['name' => $name, 'entities_id' => $parent], ['name'])->getID(); + } + + /** + * @param list $names + * @return array Ids of the created groups, by name + */ + protected function createGroups(array $names, int $entities_id, bool $recursive = false, bool $assignable = true): array + { + $ids = []; + foreach ($names as $name) { + $ids[$name] = $this->createItem(Group::class, [ + 'name' => $name, + 'entities_id' => $entities_id, + 'is_recursive' => (int) $recursive, + 'is_assign' => (int) $assignable, + ])->getID(); + } + + return $ids; + } + + /** + * Stores links of an entity, as they would be saved (the rights on the links are not checked). + * + * @param list $links Source, destination and strategy of each link + */ + protected function createLinks(int $entities_id, array $links): void + { + $inputs = []; + foreach ($links as $link) { + $inputs[] = ['entities_id' => $entities_id] + (new EscalationLink(...$link))->toRow(); + } + + $this->createItems(Group_Link::class, $inputs); + } + + /** + * @return array Stored link types of the entity, by link key (see EscalationLink::key()) + */ + protected function getEntityLinks(int $entities_id): array + { + $links = []; + foreach ((new Group_Link())->find(['entities_id' => $entities_id]) as $row) { + $links[EscalationLink::fromRow($row)->getKey()] = $row['link_type']; + } + + ksort($links); + + return $links; + } + + /** + * A child entity of the root entity, where two recursive groups of the root entity are linked + * by an inherited link of the root entity, A -> B. + * + * @return array{int, int, int} Child entity, A, B + */ + protected function createInheritedLink(): array + { + $root_id = $this->getRootEntityId(); + $child_id = $this->createChildEntity('Child', $root_id); + ['A' => $a, 'B' => $b] = $this->createGroups(['A', 'B'], $root_id, true); + $this->createLinks($root_id, [[$a, $b, LinkStrategyEnum::INHERITED]]); + + return [$child_id, $a, $b]; + } + + /** + * The HTML of the editor. + */ + protected function render(TreeEditor $editor): string + { + ob_start(); + $editor->display(); + + return ob_get_clean(); + } +} diff --git a/tests/Units/ConfigTest.php b/tests/Units/ConfigTest.php index b8844ec..a38047a 100644 --- a/tests/Units/ConfigTest.php +++ b/tests/Units/ConfigTest.php @@ -66,6 +66,7 @@ use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Tests\MoreOptionsTestCase; use PHPUnit\Framework\Attributes\DataProvider; +use Profile; class ConfigTest extends MoreOptionsTestCase { @@ -2863,4 +2864,32 @@ public function testUpdateTicketActorsOnCategoryChangeSkipsGroupNotAllowedAsAssi 'assign_technical_group_when_changing_category_ticket' => 0, ])); } + + public function testGroupFilterBypassProfiles(): void + { + $this->login(); + $field = 'escalate_group_filter_bypass_profiles'; + $admin = getItemByTypeName(Profile::class, 'Super-Admin', true); + $technician = getItemByTypeName(Profile::class, 'Technician', true); + + // Saved as sent by the form: the profiles selected, after the empty value of the select. + $root = Config::getConfig(0, false); + $this->assertTrue($root->update(['id' => $root->getID(), $field => ['', (string) $admin, (string) $technician]])); + $this->assertSame([$admin, $technician], Config::decodeProfiles(Config::getConfig(0, false)->fields[$field])); + + // A new child entity inherits them. + $child = $this->createItem(Entity::class, ['name' => 'Group filter child', 'entities_id' => 0]); + $child_config = Config::getConfig($child->getID(), false); + $this->assertSame((string) Config::CONFIG_PARENT, (string) $child_config->fields[$field]); + $this->assertSame([$admin, $technician], Config::decodeProfiles(Config::getConfig($child->getID())->fields[$field])); + + // "Inherit" wins over the profiles selected with it. + $this->assertTrue($child_config->update(['id' => $child_config->getID(), $field => [(string) Config::CONFIG_PARENT, (string) $admin]])); + $this->assertSame((string) Config::CONFIG_PARENT, (string) Config::getConfig($child->getID(), false)->fields[$field]); + + // Nothing selected: no profile, without inheriting. + $this->assertTrue($child_config->update(['id' => $child_config->getID(), $field => ['']])); + $this->assertSame('[]', Config::getConfig($child->getID(), false)->fields[$field]); + $this->assertSame([], Config::decodeProfiles(Config::getConfig($child->getID())->fields[$field])); + } } diff --git a/tests/Units/EscalationGraphTest.php b/tests/Units/EscalationGraphTest.php new file mode 100644 index 0000000..caa099b --- /dev/null +++ b/tests/Units/EscalationGraphTest.php @@ -0,0 +1,77 @@ + 2 -> 3, and 1 -> 3 skipping a level; 4 alone + $graph = new EscalationGraph([4, 3, 2, 1], [[1, 2], [2, 3], [1, 3]]); + + $this->assertSame([4 => 1, 3 => 3, 2 => 2, 1 => 1], $graph->getLevels()); + $this->assertSame([2, 3], $graph->getChildren(1)); + $this->assertSame([2, 1], $graph->getParents(3)); + $this->assertEqualsCanonicalizing([1, 2], array_keys($graph->getAncestors(3))); + $this->assertEqualsCanonicalizing([2, 3], array_keys($graph->getDescendants(1))); + $this->assertNull($graph->findCycle()); + } + + public function testCycle(): void + { + // 1 -> 2 -> 3 -> 2, then 3 -> 4 after the loop + $graph = new EscalationGraph([], [[1, 2], [2, 3], [3, 2], [3, 4]]); + + $this->assertSame([1 => 1, 2 => 1, 3 => 1, 4 => 1], $graph->getLevels()); + // In the order of the escalation, from any group of the loop + $cycle = $graph->findCycle(); + $this->assertContains($cycle, [[2, 3], [3, 2]]); + $this->assertArrayHasKey(2, $graph->getAncestors(2)); + } + + public function testAnEdgeIsAddedOnce(): void + { + $graph = new EscalationGraph(); + $graph->addEdge(1, 2); + $graph->addEdge(1, 2); + + $this->assertSame([2], $graph->getChildren(1)); + $this->assertSame([1], $graph->getParents(2)); + $this->assertSame([], $graph->getChildren(3)); + } +} diff --git a/tests/Units/EscalationLinkTest.php b/tests/Units/EscalationLinkTest.php new file mode 100644 index 0000000..5f290aa --- /dev/null +++ b/tests/Units/EscalationLinkTest.php @@ -0,0 +1,98 @@ + Child > Grandchild + private const ROOT = 0; + + private const CHILD = 1; + + private const GRANDCHILD = 2; + + /** + * Links of the pair 1 -> 2, from the deepest entity. + * + * @param list $links Strategy and entity of each link + * @return list + */ + private function createPair(array $links): array + { + $pair = []; + foreach ($links as [$strategy, $entity]) { + $pair[] = new EscalationLink(1, 2, $strategy, null, $entity); + } + + return $pair; + } + + public function testResolveAnInheritedLink(): void + { + $pair = $this->createPair([[LinkStrategyEnum::INHERITED, self::ROOT]]); + + $this->assertSame(self::ROOT, EscalationLink::resolve($pair, self::ROOT, [self::ROOT => 0])?->entities_id); + $this->assertSame(self::ROOT, EscalationLink::resolve($pair, self::GRANDCHILD, [self::ROOT => 0, self::CHILD => 1, self::GRANDCHILD => 2])?->entities_id); + // The links of the entities not searched are ignored. + $this->assertNull(EscalationLink::resolve($pair, self::CHILD, [self::CHILD => 0])); + } + + public function testABasicLinkOnlyAppliesInItsEntity(): void + { + // A basic link of the child hides the inherited link of the root, in the child only. + $pair = $this->createPair([[LinkStrategyEnum::BASIC, self::CHILD], [LinkStrategyEnum::INHERITED, self::ROOT]]); + $entities = [self::ROOT => 0, self::CHILD => 1, self::GRANDCHILD => 2]; + + $this->assertSame(self::CHILD, EscalationLink::resolve($pair, self::CHILD, $entities)?->entities_id); + $this->assertSame(self::ROOT, EscalationLink::resolve($pair, self::GRANDCHILD, $entities)?->entities_id); + // Without the links of the child itself: what the root passes down to it + $this->assertSame(self::ROOT, EscalationLink::resolve($pair, self::CHILD, [self::ROOT => 0])?->entities_id); + } + + public function testANoneLinkBlocksTheInheritance(): void + { + // Removed from the child: from its sub-entities too, but not from the root. + $pair = $this->createPair([[LinkStrategyEnum::NONE, self::CHILD], [LinkStrategyEnum::INHERITED, self::ROOT]]); + $entities = [self::ROOT => 0, self::CHILD => 1, self::GRANDCHILD => 2]; + + $this->assertNull(EscalationLink::resolve($pair, self::CHILD, $entities)); + $this->assertNull(EscalationLink::resolve($pair, self::GRANDCHILD, $entities)); + $this->assertSame(self::ROOT, EscalationLink::resolve($pair, self::ROOT, [self::ROOT => 0])?->entities_id); + } +} diff --git a/tests/Units/EscalationTest.php b/tests/Units/EscalationTest.php index 9b23f14..e7068fd 100644 --- a/tests/Units/EscalationTest.php +++ b/tests/Units/EscalationTest.php @@ -42,9 +42,13 @@ use Log; use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Escalation; +use GlpiPlugin\Moreoptions\EscalationTree\EscalationLink; +use GlpiPlugin\Moreoptions\Group_Link; +use GlpiPlugin\Moreoptions\LinkStrategy\LinkStrategyEnum; use GlpiPlugin\Moreoptions\Tests\MoreOptionsTestCase; use PHPUnit\Framework\Attributes\DataProvider; use Problem; +use Profile; use Session; use Symfony\Component\DomCrawler\Crawler; use Ticket; @@ -585,6 +589,166 @@ public function testEscalationBlocker(string $itemtype): void $this->hasSessionMessages(ERROR, ['This group is not visible from the entity of the item.']); } + /** + * With the group filter active, the item can only be escalated to the groups of the next level + * of the escalation tree, from the groups assigned to it. + * + * @param class-string $itemtype + */ + #[DataProvider('itemtypeProvider')] + public function testGroupFilter(string $itemtype): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id, ['escalate_group_filter_is_active' => 1]); + + // A -> B, A -> C, B -> D + $a = $this->createGroup($entities_id, 'Level 1'); + $b = $this->createGroup($entities_id, 'Level 2 - B'); + $c = $this->createGroup($entities_id, 'Level 2 - C'); + $d = $this->createGroup($entities_id, 'Level 3'); + $this->createItems(Group_Link::class, [ + ['entities_id' => $entities_id] + (new EscalationLink($a->getID(), $b->getID(), LinkStrategyEnum::BASIC))->toRow(), + ['entities_id' => $entities_id] + (new EscalationLink($a->getID(), $c->getID(), LinkStrategyEnum::BASIC))->toRow(), + ['entities_id' => $entities_id] + (new EscalationLink($b->getID(), $d->getID(), LinkStrategyEnum::BASIC))->toRow(), + ]); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + + // No group assigned: no level to start from, any group is allowed + $this->assertNull(Escalation::getGroupFilter($item)); + + $this->createItem($item->grouplinkclass, [ + $item->getForeignKeyField() => $item->getID(), + 'groups_id' => $a->getID(), + 'type' => CommonITILActor::ASSIGN, + ]); + + $this->assertSame([$b->getID(), $c->getID()], Escalation::getGroupFilter($item)); + $this->assertNull(Escalation::getEscalationBlocker($item, $b->getID())); + $this->assertSame( + 'This group is not in the next level of the escalation tree.', + Escalation::getEscalationBlocker($item, $d->getID()), + ); + + // The escalation to a group out of the next level is refused + $this->assertFalse((new Escalation())->add([ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $d->getID(), + ])); + $this->hasSessionMessages(ERROR, ['This group is not in the next level of the escalation tree.']); + $this->assertSame([$a->getID()], $this->getAssignedGroupIds($item)); + + // Escalate to B: the next level is now D + $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $b->getID(), + ]); + $this->assertSame([$d->getID()], Escalation::getGroupFilter($item)); + + $this->assertTrue(Escalation::isGroupFilterApplied($item)); + + // A profile allowed to bypass the filter can escalate to any group + $config = Config::getConfig($entities_id, false); + $this->updateItem(Config::class, $config->getID(), [ + 'escalate_group_filter_bypass_profiles' => [(string) $_SESSION['glpiactiveprofile']['id']], + ], ['escalate_group_filter_bypass_profiles']); + $this->assertFalse(Escalation::isGroupFilterApplied($item)); + $this->assertNull(Escalation::getGroupFilter($item)); + $this->assertNull(Escalation::getEscalationBlocker($item, $c->getID())); + + // Another profile does not bypass it + $this->updateItem(Config::class, $config->getID(), [ + 'escalate_group_filter_bypass_profiles' => [(string) getItemByTypeName(Profile::class, 'Technician', true)], + ], ['escalate_group_filter_bypass_profiles']); + $this->assertSame([$d->getID()], Escalation::getGroupFilter($item)); + + // The filter not active: any group is allowed + $this->updateItem(Config::class, $config->getID(), ['escalate_group_filter_is_active' => 0]); + $this->assertFalse(Escalation::isGroupFilterApplied($item)); + $this->assertNull(Escalation::getGroupFilter($item)); + } + + /** + * With several groups assigned, the groups allowed by the filter are the union of their next + * levels, without duplicates. + */ + public function testGroupFilterWithSeveralAssignedGroups(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id, ['escalate_group_filter_is_active' => 1]); + + // A -> C, A -> D, B -> D, B -> E + $a = $this->createGroup($entities_id, 'Source A'); + $b = $this->createGroup($entities_id, 'Source B'); + $c = $this->createGroup($entities_id, 'Target C'); + $d = $this->createGroup($entities_id, 'Target D'); + $e = $this->createGroup($entities_id, 'Target E'); + $this->createItems(Group_Link::class, [ + ['entities_id' => $entities_id] + (new EscalationLink($a->getID(), $c->getID(), LinkStrategyEnum::BASIC))->toRow(), + ['entities_id' => $entities_id] + (new EscalationLink($a->getID(), $d->getID(), LinkStrategyEnum::BASIC))->toRow(), + ['entities_id' => $entities_id] + (new EscalationLink($b->getID(), $d->getID(), LinkStrategyEnum::BASIC))->toRow(), + ['entities_id' => $entities_id] + (new EscalationLink($b->getID(), $e->getID(), LinkStrategyEnum::BASIC))->toRow(), + ]); + + $item = $this->createItem(Ticket::class, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => [$a->getID(), $b->getID()], + ]); + + $this->assertSame([$c->getID(), $d->getID(), $e->getID()], Escalation::getGroupFilter($item)); + } + + /** + * With the group filter active, the escalation form only offers the groups of the next level, + * and cannot be submitted when there is none. + */ + public function testEscalationFormWithoutNextLevel(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id, ['escalate_group_filter_is_active' => 1]); + + $item = $this->createItem(Ticket::class, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => [$this->createGroup($entities_id, 'Last level')->getID()], + ]); + $this->assertInstanceOf(Ticket::class, $item); + + ob_start(); + Escalation::showEscalationForm($item); + $crawler = new Crawler(ob_get_clean()); + + $this->assertStringContainsString( + 'No group in the next level of the escalation tree.', + $crawler->filter('.alert-warning')->text(), + ); + $this->assertNotNull($crawler->filter('button[type="submit"][name="add"]')->attr('disabled')); + + // Without the filter, the form can be submitted + $this->enableEscalation($entities_id, ['escalate_group_filter_is_active' => 0]); + ob_start(); + Escalation::showEscalationForm($item); + $crawler = new Crawler(ob_get_clean()); + + $this->assertCount(0, $crawler->filter('.alert-warning')); + $this->assertNull($crawler->filter('button[type="submit"][name="add"]')->attr('disabled')); + } + /** * Enable the escalation option for the given entity. * diff --git a/tests/Units/EscalationTreeTest.php b/tests/Units/EscalationTreeTest.php new file mode 100644 index 0000000..dea1cae --- /dev/null +++ b/tests/Units/EscalationTreeTest.php @@ -0,0 +1,316 @@ + $names + * @return array{EscalationTree, array} The tree, and the ids of the groups, by name + */ + private function createTree(array $names): array + { + $entities_id = $this->getRootEntityId(); + $ids = $this->createGroups($names, $entities_id); + + return [EscalationTree::load($entities_id, array_values($ids)), $ids]; + } + + /** + * @param array $nodes + * @return list + */ + private function getIds(array $nodes): array + { + $ids = []; + foreach ($nodes as $node) { + $ids[] = $node->id; + } + + sort($ids); + + return $ids; + } + + public function testHierarchy(): void + { + $this->login(); + [$tree, $ids] = $this->createTree(['N1a', 'N1b', 'N2', 'N3a', 'N3b']); + foreach ($ids as $id) { + $this->assertNotNull($tree->getNode($id)); + } + + // Two groups escalate to N2, which escalates to two groups; N1a also skips a level. + $this->assertNotNull($tree->link($ids['N1a'], $ids['N2'])); + $this->assertNotNull($tree->link($ids['N1b'], $ids['N2'])); + $this->assertNotNull($tree->link($ids['N2'], $ids['N3a'])); + $this->assertNotNull($tree->link($ids['N2'], $ids['N3b'])); + $this->assertNotNull($tree->link($ids['N1a'], $ids['N3b'])); + $this->assertNull($tree->link($ids['N2'], $ids['N2'])); + + $n2 = $tree->getNode($ids['N2']); + $this->assertNotNull($n2); + $this->assertSame([$ids['N1a'], $ids['N1b']], $this->getIds($n2->getParents())); + $this->assertSame([$ids['N3a'], $ids['N3b']], $this->getIds($n2->getChildren())); + $this->assertTrue($tree->getNode($ids['N1a'])?->isRoot()); + $this->assertFalse($n2->isRoot()); + + $this->assertSame([ + $ids['N1a'] => 1, + $ids['N1b'] => 1, + $ids['N2'] => 2, + $ids['N3a'] => 3, + $ids['N3b'] => 3, + ], $tree->getLevels()); + + // The links of N2, to the groups it escalates to and from the groups escalating to it + $this->assertSame( + [sprintf('%d-%d', $ids['N2'], $ids['N3a']), sprintf('%d-%d', $ids['N2'], $ids['N3b'])], + array_keys($tree->getLinksOf($ids['N2'], 'to')), + ); + $this->assertSame( + [sprintf('%d-%d', $ids['N1a'], $ids['N2']), sprintf('%d-%d', $ids['N1b'], $ids['N2'])], + array_keys($tree->getLinksOf($ids['N2'], 'from')), + ); + + // Removing a group removes its links: N3b stays below N1a only. + $tree->removeNode($ids['N2']); + $this->assertNull($tree->getNode($ids['N2'])); + $this->assertSame([sprintf('%d-%d', $ids['N1a'], $ids['N3b'])], array_keys($tree->getLinks())); + $this->assertSame([], $tree->getNode($ids['N1b'])?->getChildren()); + $this->assertSame(2, $tree->getLevels()[$ids['N3b']]); + $this->assertSame(1, $tree->getLevels()[$ids['N3a']]); + } + + public function testCannotLinkToAnAncestor(): void + { + $this->login(); + [$tree, $ids] = $this->createTree(['A', 'B', 'C', 'D']); + $tree->link($ids['A'], $ids['B']); + $tree->link($ids['B'], $ids['C']); + + $a = $tree->getNode($ids['A']); + $c = $tree->getNode($ids['C']); + $this->assertNotNull($a); + $this->assertNotNull($c); + $this->assertSame([$ids['A'], $ids['B']], $this->getIds($c->getAncestors())); + $this->assertSame([$ids['B'], $ids['C']], $this->getIds($a->getDescendants())); + $this->assertTrue($a->isAncestorOf($c)); + $this->assertFalse($c->isAncestorOf($a)); + + // Direct parent, indirect ancestor, itself + $this->assertFalse($tree->canLink($ids['B'], $ids['A'])); + $this->assertNull($tree->link($ids['B'], $ids['A'])); + $this->assertFalse($tree->canLink($ids['C'], $ids['A'])); + $this->assertNull($tree->link($ids['C'], $ids['A'])); + $this->assertFalse($tree->canLink($ids['A'], $ids['A'])); + + // Skipping a level, or going to another branch, is allowed; so is a link already there. + $this->assertTrue($tree->canLink($ids['A'], $ids['C'])); + $this->assertTrue($tree->canLink($ids['C'], $ids['D'])); + $this->assertTrue($tree->canLink($ids['A'], $ids['B'])); + $this->assertSame([sprintf('%d-%d', $ids['A'], $ids['B']), sprintf('%d-%d', $ids['B'], $ids['C'])], array_keys($tree->getLinks())); + + // A draft making a loop loses the link closing it. + $state = $tree->toState(); + $state['links'][] = ['from' => $ids['C'], 'to' => $ids['A'], 'type' => LinkStrategyEnum::BASIC->value]; + $copy = EscalationTree::fromState($tree->entities_id, $state); + $this->assertSame([sprintf('%d-%d', $ids['A'], $ids['B']), sprintf('%d-%d', $ids['B'], $ids['C'])], array_keys($copy->getLinks())); + } + + public function testLoopAlreadySaved(): void + { + $this->login(); + $entities_id = $this->getRootEntityId(); + ['A' => $a, 'B' => $b, 'C' => $c] = $this->createGroups(['A', 'B', 'C'], $entities_id); + $this->createLinks($entities_id, [ + [$a, $b, LinkStrategyEnum::BASIC], + [$b, $c, LinkStrategyEnum::BASIC], + [$c, $a, LinkStrategyEnum::BASIC], + ]); + + // No root: every group falls back to level 1 instead of looping. + $tree = EscalationTree::load($entities_id); + $this->assertSame([$a => 1, $b => 1, $c => 1], $tree->getLevels()); + $this->assertCount(3, $tree->getNode($a)?->getAncestors() ?? []); + } + + public function testLoadOnlyPlacesTheGroupsOfTheLinksPlaced(): void + { + $this->login(); + $entities_id = $this->getRootEntityId(); + ['A' => $a] = $this->createGroups(['A'], $entities_id); + ['B' => $b] = $this->createGroups(['B'], $entities_id, false, false); + $this->createLinks($entities_id, [[$a, $b, LinkStrategyEnum::BASIC]]); + + // B cannot be assigned any more: neither its link nor A are placed. + $tree = EscalationTree::load($entities_id); + $this->assertNull($tree->getNode($a)); + $this->assertSame([], $tree->getLinks()); + } + + public function testAddingAGroupShowsItsReplicatedLinks(): void + { + $this->login(); + [$child_id, $a, $b] = $this->createInheritedLink(); + + // Placed again (from a draft forged without it), B gets back A -> B: B cannot escalate to A. + $tree = EscalationTree::fromState($child_id, ['nodes' => [$a]]); + $this->assertSame([], $tree->getLinks()); + $tree->addNode($b); + $this->assertSame([sprintf('%d-%d', $a, $b)], array_keys($tree->getLinks())); + $this->assertFalse($tree->canLink($b, $a)); + } + + public function testReplicatedLinksHideAnEntityOutOfReach(): void + { + $this->login(); + [$child_id, $a, $b] = $this->createInheritedLink(); + $root_id = $this->getRootEntityId(); + $this->assertStringNotContainsString('#', (string) EscalationTree::load($child_id)->getLink(sprintf('%d-%d', $a, $b))?->origin); + + // Without access to the root entity, its name is not shown. + $this->setEntity($child_id, false); + $this->assertSame(sprintf('Hidden entity #%d', $root_id), EscalationTree::load($child_id)->getLink(sprintf('%d-%d', $a, $b))?->origin); + } + + public function testStrategyAndState(): void + { + $this->login(); + [$tree, $ids] = $this->createTree(['A', 'B']); + $key = $tree->link($ids['A'], $ids['B'])?->getKey(); + $this->assertIsString($key); + + $tree->setStrategy($key, LinkStrategyEnum::INHERITED); + $this->assertSame(LinkStrategyEnum::INHERITED, $tree->getLink($key)?->strategy); + // A link that is not drawn cannot be chosen. + $tree->setStrategy($key, LinkStrategyEnum::NONE); + $this->assertSame(LinkStrategyEnum::INHERITED, $tree->getLink($key)?->strategy); + + $copy = EscalationTree::fromState($tree->entities_id, $tree->toState()); + $this->assertSame($tree->toState(), $copy->toState()); + $this->assertSame([$ids['B']], $this->getIds($copy->getNode($ids['A'])?->getChildren() ?? [])); + } + + public function testRemovingAGroupRemovesTheReplicatedLinksItsLinksReplace(): void + { + $this->login(); + [$child_id, $a, $b] = $this->createInheritedLink(); + // In the child entity, A -> B is replaced by a link of its own. + $this->createLinks($child_id, [[$a, $b, LinkStrategyEnum::BASIC]]); + + $tree = EscalationTree::load($child_id); + $tree->removeNode($a); + $this->assertNull($tree->getLink(sprintf('%d-%d', $a, $b))); + $this->assertSame([sprintf('%d-%d', $a, $b)], $tree->toState()['removed']); + + // Saved, the replicated link does not come back with the group. + $this->assertNull($tree->save()); + $tree = EscalationTree::load($child_id); + $this->assertNull($tree->getNode($a)); + $this->assertSame([], $tree->getLinks()); + } + + public function testFromStateIgnoresForgedLinks(): void + { + $this->login(); + [$child_id, $a, $b] = $this->createInheritedLink(); + + // The replicated link, removed, and given as a link of the entity, twice + $tree = EscalationTree::fromState($child_id, [ + 'nodes' => [$a, $b], + 'links' => array_fill(0, 3, ['from' => $a, 'to' => $b, 'type' => LinkStrategyEnum::BASIC->value]), + 'removed' => [sprintf('%d-%d', $a, $b)], + ]); + $this->assertSame([], $tree->toState()['links']); + $this->assertSame([sprintf('%d-%d', $a, $b)], $tree->toState()['removed']); + $this->assertSame([], $tree->getLinks()); + + // A link given several times is read once. + $tree = EscalationTree::fromState($child_id, [ + 'nodes' => [$a, $b], + 'links' => array_fill(0, 3, ['from' => $b, 'to' => $a, 'type' => LinkStrategyEnum::BASIC->value]), + 'removed' => [sprintf('%d-%d', $a, $b)], + ]); + $this->assertSame([['from' => $b, 'to' => $a, 'type' => LinkStrategyEnum::BASIC->value]], $tree->toState()['links']); + } + + public function testSaveRefusesALoopInASubEntity(): void + { + $this->login(); + [$child_id, $a, $b] = $this->createInheritedLink(); + // B -> A in a sub-entity of the child entity + $grandchild_id = $this->createChildEntity('Grandchild', $child_id); + $this->createLinks($grandchild_id, [[$b, $a, LinkStrategyEnum::BASIC]]); + // The root entity cannot replicate A -> B again in the child entity, which removed it. + $this->createLinks($child_id, [[$a, $b, LinkStrategyEnum::NONE]]); + $this->assertNull(Group_Link::findLoop($this->getRootEntityId())); + + // Restoring it in the child entity would make a loop in the grandchild one: nothing is saved. + $tree = EscalationTree::load($child_id, [$a, $b]); + $this->assertTrue($tree->link($a, $b)?->isReplicated()); + $loop = $tree->save(); + $this->assertSame($grandchild_id, $loop['entities_id'] ?? null); + $this->assertSame([sprintf('%d-%d', $a, $b) => LinkStrategyEnum::NONE->value], $this->getEntityLinks($child_id)); + } + + public function testEscalationLink(): void + { + $row = ['groups_id_source' => '3', 'groups_id_destination' => '5', 'link_type' => LinkStrategyEnum::INHERITED->value]; + $link = EscalationLink::fromRow($row, 'Root'); + $this->assertSame([3, 5, LinkStrategyEnum::INHERITED], [$link->source, $link->destination, $link->strategy]); + $this->assertSame('3-5', $link->getKey()); + $this->assertTrue($link->isReplicated()); + $this->assertFalse(EscalationLink::fromRow($row)->isReplicated()); + $this->assertSame(['groups_id_source' => 3, 'groups_id_destination' => 5, 'link_type' => LinkStrategyEnum::INHERITED->value], $link->toRow()); + + // An unknown stored strategy reads as the default one. + $this->assertSame(LinkStrategyEnum::NONE, EscalationLink::fromRow(['link_type' => 'unknown'] + $row)->strategy); + + // Ends of a link of group 3 with group 5, both ways + $this->assertSame(['to', 'from'], EscalationLink::DIRECTIONS); + } +} diff --git a/tests/Units/Group_LinkTest.php b/tests/Units/Group_LinkTest.php new file mode 100644 index 0000000..9d41b59 --- /dev/null +++ b/tests/Units/Group_LinkTest.php @@ -0,0 +1,395 @@ + $links Source, destination and strategy of each link + * @return list + */ + private function toLinks(array $links): array + { + $objects = []; + foreach ($links as $link) { + $objects[] = new EscalationLink(...$link); + } + + return $objects; + } + + /** + * @param list $links + * @return list Keys of the links (see EscalationLink::key()) + */ + private function getKeys(array $links): array + { + $keys = []; + foreach ($links as $link) { + $keys[] = $link->getKey(); + } + + return $keys; + } + + /** + * @return array Entity of each link applying in the given entity, by link key, sorted + */ + private function getApplyingEntities(int $entities_id): array + { + $entities = []; + foreach (Group_Link::getLinksForEntity($entities_id) as $link) { + $entities[$link->getKey()] = $link->entities_id; + } + + ksort($entities); + + return $entities; + } + + public function testSaveLinksForEntity(): void + { + $this->login(); + $entities_id = $this->getRootEntityId(); + ['A' => $a, 'B' => $b, 'C' => $c] = $this->createGroups(['A', 'B', 'C'], $entities_id); + $not_assignable = $this->createGroups(['Not assignable'], $entities_id, assignable: false)['Not assignable']; + + // A link to itself, or to a group that cannot be linked, is ignored. + Group_Link::saveLinksForEntity($entities_id, $this->toLinks([ + [$a, $b, LinkStrategyEnum::BASIC], + [$b, $c, LinkStrategyEnum::INHERITED], + [$a, $a, LinkStrategyEnum::BASIC], + [$a, $not_assignable, LinkStrategyEnum::BASIC], + ])); + $this->assertSame([ + sprintf('%d-%d', $a, $b) => LinkStrategyEnum::BASIC->value, + sprintf('%d-%d', $b, $c) => LinkStrategyEnum::INHERITED->value, + ], $this->getEntityLinks($entities_id)); + + $link = new Group_Link(); + $this->assertTrue($link->getFromDBByCrit(['groups_id_source' => $a, 'groups_id_destination' => $b])); + $link_id = $link->getID(); + + // Updated in place, the other one is deleted. + Group_Link::saveLinksForEntity($entities_id, $this->toLinks([[$a, $b, LinkStrategyEnum::INHERITED]])); + $this->assertSame([sprintf('%d-%d', $a, $b) => LinkStrategyEnum::INHERITED->value], $this->getEntityLinks($entities_id)); + $this->assertTrue($link->getFromDB($link_id)); + + Group_Link::saveLinksForEntity($entities_id, []); + $this->assertSame([], $this->getEntityLinks($entities_id)); + } + + public function testSaveLinksForEntityKeepsNoneLinks(): void + { + $this->login(); + $entities_id = $this->getRootEntityId(); + ['A' => $a, 'B' => $b] = $this->createGroups(['A', 'B'], $entities_id); + $this->createLinks($entities_id, [[$a, $b, LinkStrategyEnum::NONE]]); + + Group_Link::saveLinksForEntity($entities_id, []); + $this->assertSame([sprintf('%d-%d', $a, $b) => LinkStrategyEnum::NONE->value], $this->getEntityLinks($entities_id)); + + // Drawing the link again replaces it. + Group_Link::saveLinksForEntity($entities_id, $this->toLinks([[$a, $b, LinkStrategyEnum::BASIC]])); + $this->assertSame([sprintf('%d-%d', $a, $b) => LinkStrategyEnum::BASIC->value], $this->getEntityLinks($entities_id)); + } + + public function testRootEntityInheritsNothing(): void + { + $this->login(); + ['A' => $a, 'B' => $b] = $this->createGroups(['A', 'B'], 0); + $this->createLinks(0, [[$a, $b, LinkStrategyEnum::INHERITED]]); + + // The links of the root entity are its own, not replicated from itself. + $this->assertNotContains(sprintf('%d-%d', $a, $b), $this->getKeys(Group_Link::getInheritedLinks(0))); + $this->assertContains(sprintf('%d-%d', $a, $b), $this->getKeys(Group_Link::getLinksForEntity(0))); + } + + public function testGetLinksForEntity(): void + { + $this->login(); + $root_id = $this->getRootEntityId(); + $child_id = $this->createChildEntity('Child', $root_id); + $grandchild_id = $this->createChildEntity('Grandchild', $child_id); + ['A' => $a, 'B' => $b, 'C' => $c] = $this->createGroups(['A', 'B', 'C'], $root_id, true); + $this->createLinks($root_id, [ + [$a, $b, LinkStrategyEnum::INHERITED], + [$a, $c, LinkStrategyEnum::BASIC], + [$b, $c, LinkStrategyEnum::INHERITED], + ]); + $this->createLinks($child_id, [ + [$b, $c, LinkStrategyEnum::NONE], + [$c, $a, LinkStrategyEnum::BASIC], + ]); + + $expected = [sprintf('%d-%d', $a, $b) => $root_id, sprintf('%d-%d', $a, $c) => $root_id, sprintf('%d-%d', $b, $c) => $root_id]; + ksort($expected); + $this->assertSame($expected, $this->getApplyingEntities($root_id)); + + // Only the inherited links of the root apply, minus the one removed by the "none" link. + $expected = [sprintf('%d-%d', $a, $b) => $root_id, sprintf('%d-%d', $c, $a) => $child_id]; + ksort($expected); + $this->assertSame($expected, $this->getApplyingEntities($child_id)); + + // The basic link of the child does not apply to its sub-entities, and the "none" link + // still stops the inherited one. + $this->assertSame([sprintf('%d-%d', $a, $b) => $root_id], $this->getApplyingEntities($grandchild_id)); + } + + public function testBasicLinkOfAMiddleEntityDoesNotHideAnInheritedOne(): void + { + $this->login(); + $root_id = $this->getRootEntityId(); + $child_id = $this->createChildEntity('Child', $root_id); + $grandchild_id = $this->createChildEntity('Grandchild', $child_id); + ['A' => $a, 'B' => $b] = $this->createGroups(['A', 'B'], $root_id, true); + $this->createLinks($root_id, [[$a, $b, LinkStrategyEnum::INHERITED]]); + $this->createLinks($child_id, [[$a, $b, LinkStrategyEnum::BASIC]]); + + // The basic link only applies in the child entity: the grandchild gets the one of the root. + $this->assertSame([$child_id], array_column(Group_Link::getLinksForEntity($child_id), 'entities_id')); + $this->assertSame([$root_id], array_column(Group_Link::getLinksForEntity($grandchild_id), 'entities_id')); + } + + public function testSaveLinksForEntityRestoresAndKeepsUnmanagedLinks(): void + { + $this->login(); + $entities_id = $this->getRootEntityId(); + ['A' => $a, 'B' => $b, 'C' => $c] = $this->createGroups(['A', 'B', 'C'], $entities_id); + $this->createLinks($entities_id, [ + [$a, $b, LinkStrategyEnum::NONE], + [$a, $c, LinkStrategyEnum::BASIC], + ]); + // C cannot be assigned any more: its link is not managed by the graph, and stays. + $this->updateItem(Group::class, $c, ['is_assign' => 0]); + + Group_Link::saveLinksForEntity($entities_id, [], [EscalationLink::key($a, $b)]); + $this->assertSame([sprintf('%d-%d', $a, $c) => LinkStrategyEnum::BASIC->value], $this->getEntityLinks($entities_id)); + } + + public function testSaveLinksForEntityReplacingAllDeletesUnmanagedLinks(): void + { + $this->login(); + $entities_id = $this->getRootEntityId(); + ['A' => $a, 'B' => $b] = $this->createGroups(['A', 'B'], $entities_id); + $this->createLinks($entities_id, [[$a, $b, LinkStrategyEnum::BASIC]]); + $this->updateItem(Group::class, $b, ['is_assign' => 0]); + + // The graph was reset: no link is left, even between groups it does not manage. + Group_Link::saveLinksForEntity($entities_id, [], [], true); + $this->assertSame([], $this->getEntityLinks($entities_id)); + } + + public function testFindLoop(): void + { + $this->login(); + $root_id = $this->getRootEntityId(); + $child_id = $this->createChildEntity('Child', $root_id); + ['A' => $a, 'B' => $b, 'C' => $c] = $this->createGroups(['A', 'B', 'C'], $root_id, true); + $this->assertNull(Group_Link::findLoop($root_id)); + + // A -> B -> C in the child entity, then C -> A inherited from the root entity + Group_Link::saveLinksForEntity($child_id, $this->toLinks([[$a, $b, LinkStrategyEnum::BASIC], [$b, $c, LinkStrategyEnum::BASIC]])); + $this->assertNull(Group_Link::findLoop($root_id)); + Group_Link::saveLinksForEntity($root_id, $this->toLinks([[$c, $a, LinkStrategyEnum::INHERITED]])); + + $loop = Group_Link::findLoop($root_id); + $this->assertNotNull($loop); + $this->assertSame($child_id, $loop['entities_id']); + $groups = $loop['groups']; + sort($groups); + $this->assertSame([$a, $b, $c], $groups); + } + + public function testVersion(): void + { + $this->login(); + $entities_id = $this->getRootEntityId(); + ['A' => $a, 'B' => $b] = $this->createGroups(['A', 'B'], $entities_id); + $before = Group_Link::getVersion(Group_Link::getRowsOfEntity($entities_id)); + $this->assertSame($before, Group_Link::getVersion(Group_Link::getRowsOfEntity($entities_id))); + Group_Link::saveLinksForEntity($entities_id, $this->toLinks([[$a, $b, LinkStrategyEnum::BASIC]])); + $this->assertNotSame($before, Group_Link::getVersion(Group_Link::getRowsOfEntity($entities_id))); + } + + public function testLinksOfPurgedItemsAreDeleted(): void + { + $this->login(); + $root_id = $this->getRootEntityId(); + $child_id = $this->createChildEntity('Child', $root_id); + ['A' => $a, 'B' => $b, 'C' => $c] = $this->createGroups(['A', 'B', 'C'], $root_id, true); + Group_Link::saveLinksForEntity($root_id, $this->toLinks([[$a, $b, LinkStrategyEnum::BASIC], [$b, $c, LinkStrategyEnum::BASIC]])); + Group_Link::saveLinksForEntity($child_id, $this->toLinks([[$a, $c, LinkStrategyEnum::BASIC]])); + + $this->deleteItem(Group::class, $b, true); + $this->assertSame([], $this->getEntityLinks($root_id)); + $this->assertCount(1, $this->getEntityLinks($child_id)); + + $this->deleteItem(Entity::class, $child_id, true); + $this->assertSame([], $this->getEntityLinks($child_id)); + } + + public function testLinksAreOnlyChangedFromTheEscalationTab(): void + { + $this->login(); + $entities_id = $this->getRootEntityId(); + ['A' => $a, 'B' => $b] = $this->createGroups(['A', 'B'], $entities_id); + $this->createLinks($entities_id, [[$a, $b, LinkStrategyEnum::BASIC]]); + + // Not from the generic form, list, massive actions or API of GLPI, even as super-admin + $this->assertFalse(Group_Link::canCreate()); + $this->assertFalse(Group_Link::canView()); + $this->assertFalse(Group_Link::canUpdate()); + $this->assertFalse(Group_Link::canDelete()); + $this->assertFalse(Group_Link::canPurge()); + + $link = new Group_Link(); + $this->assertTrue($link->getFromDBByCrit(['groups_id_source' => $a, 'groups_id_destination' => $b])); + $this->assertFalse($link->canCreateItem()); + $this->assertFalse($link->canViewItem()); + $this->assertFalse($link->canUpdateItem()); + $this->assertFalse($link->canDeleteItem()); + $this->assertFalse($link->canPurgeItem()); + } + + public function testNamesOfWhatTheUserCannotSee(): void + { + $this->login(); + $root_id = $this->getRootEntityId(); + $child_id = $this->createChildEntity('Child', $root_id); + ['Recursive' => $recursive] = $this->createGroups(['Recursive'], $root_id, true); + ['Local' => $local] = $this->createGroups(['Local'], $root_id); + ['Own' => $own] = $this->createGroups(['Own'], $child_id); + $this->setEntity($child_id, true); + + // From the child entity, a non recursive group of the root entity is hidden. + $this->assertSame([ + $recursive => ['name' => 'Recursive', 'visible' => true], + $local => ['name' => sprintf('Hidden group #%d', $local), 'visible' => false], + $own => ['name' => 'Own', 'visible' => true], + ], Group_Link::getGroupNames([$recursive, $local, $own])); + $this->assertSame([], Group_Link::getGroupNames([])); + + // So is the root entity. + $this->assertSame(sprintf('Hidden entity #%d', $root_id), Group_Link::getEntityName($root_id)); + $this->assertSame(Dropdown::getDropdownName(Entity::getTable(), $child_id), Group_Link::getEntityName($child_id)); + $this->assertStringContainsString('Child', Group_Link::getEntityName($child_id)); + } + + public function testNextLevelGroups(): void + { + $this->login(); + $root_id = $this->getRootEntityId(); + $child_id = $this->createChildEntity('Child', $root_id); + $ids = $this->createGroups(['A', 'B', 'C', 'D', 'E'], $root_id, true); + // In the root entity: A -> B (inherited), A -> C (basic), B -> D (inherited) + $this->createLinks($root_id, [ + [$ids['A'], $ids['B'], LinkStrategyEnum::INHERITED], + [$ids['A'], $ids['C'], LinkStrategyEnum::BASIC], + [$ids['B'], $ids['D'], LinkStrategyEnum::INHERITED], + ]); + // In the child entity: A -> B removed, A -> E added + $this->createLinks($child_id, [ + [$ids['A'], $ids['B'], LinkStrategyEnum::NONE], + [$ids['A'], $ids['E'], LinkStrategyEnum::BASIC], + ]); + + // Only the next level + $this->assertSame([$ids['B'], $ids['C']], Group_Link::getNextLevelGroups($ids['A'], $root_id)); + // The links applying in the entity: the inherited ones, not the ones removed + $this->assertSame([$ids['E']], Group_Link::getNextLevelGroups($ids['A'], $child_id)); + $this->assertSame([$ids['D']], Group_Link::getNextLevelGroups($ids['B'], $child_id)); + // The last level escalates to no group. + $this->assertSame([], Group_Link::getNextLevelGroups($ids['D'], $child_id)); + } + + public function testLinksOnlyApplyBetweenGroupsAssignableInTheEntity(): void + { + $this->login(); + $root_id = $this->getRootEntityId(); + $child_id = $this->createChildEntity('Child', $root_id); + $a = $this->createGroups(['A'], $root_id, true)['A']; + // Not recursive: not visible in the child entity + $local = $this->createGroups(['Local'], $root_id)['Local']; + $this->createLinks($root_id, [[$local, $a, LinkStrategyEnum::INHERITED], [$a, $local, LinkStrategyEnum::INHERITED]]); + + // It applies in the root entity only. + $this->assertCount(2, Group_Link::getLinksForEntity($root_id)); + $this->assertSame([$local], Group_Link::getNextLevelGroups($a, $root_id)); + $this->assertSame([], Group_Link::getLinksForEntity($child_id)); + $this->assertSame([], Group_Link::getInheritedLinks($child_id)); + $this->assertSame([], Group_Link::getNextLevelGroups($a, $child_id)); + + // Recursive, it applies in the child entity too. + $this->updateItem(Group::class, $local, ['is_recursive' => 1]); + $this->assertCount(2, Group_Link::getLinksForEntity($child_id)); + $this->assertSame([$local], Group_Link::getNextLevelGroups($a, $child_id)); + + // Not assignable any more, it applies nowhere: no ticket can be escalated to it. + $this->updateItem(Group::class, $local, ['is_assign' => 0]); + $this->assertSame([], Group_Link::getLinksForEntity($root_id)); + $this->assertSame([], Group_Link::getLinksForEntity($child_id)); + $this->assertSame([], Group_Link::getNextLevelGroups($a, $root_id)); + } + + public function testLinksArePurgedWithTheirGroupOrEntity(): void + { + $this->login(); + $root_id = $this->getRootEntityId(); + $child_id = $this->createChildEntity('Child', $root_id); + ['A' => $a, 'B' => $b, 'C' => $c] = $this->createGroups(['A', 'B', 'C'], $root_id, true); + $this->createLinks($root_id, [[$a, $b, LinkStrategyEnum::INHERITED], [$c, $a, LinkStrategyEnum::BASIC], [$b, $c, LinkStrategyEnum::BASIC]]); + $this->createLinks($child_id, [[$b, $c, LinkStrategyEnum::NONE]]); + + // The links of a purged group, from it or to it, are deleted. + $this->deleteItem(Group::class, $a, true); + $this->assertSame([sprintf('%d-%d', $b, $c) => LinkStrategyEnum::BASIC->value], $this->getEntityLinks($root_id)); + + // The links of a purged entity are deleted. + $this->deleteItem(Entity::class, $child_id, true); + $this->assertSame([], $this->getEntityLinks($child_id)); + $this->assertSame([sprintf('%d-%d', $b, $c) => LinkStrategyEnum::BASIC->value], $this->getEntityLinks($root_id)); + } +} diff --git a/tests/Units/TreeEditorTest.php b/tests/Units/TreeEditorTest.php new file mode 100644 index 0000000..2bb2023 --- /dev/null +++ b/tests/Units/TreeEditorTest.php @@ -0,0 +1,798 @@ + B, and a basic one, C -> D. The child entity is the active one. + * + * @return array{Group, array} + */ + private function createGraphData(): array + { + [$child_id, $a, $b] = $this->createInheritedLink(); + $root_id = $this->getRootEntityId(); + $ids = ['A' => $a, 'B' => $b] + $this->createGroups(['C', 'D'], $root_id, true); + $this->createLinks($root_id, [[$ids['C'], $ids['D'], LinkStrategyEnum::BASIC]]); + $group = $this->createItem(Group::class, [ + 'name' => 'Current', + 'entities_id' => $child_id, + 'is_assign' => 1, + ]); + $ids['Current'] = $group->getID(); + + $this->setEntity($child_id, true); + + return [$group, $ids]; + } + + /** + * @return int Entity of the tree edited: the one of the group Current + */ + private function getEntity(Group $group): int + { + return (int) $group->fields['entities_id']; + } + + /** + * A field of the data of the links given to Cytoscape.js. + * + * @param array $vars Template variables + * @return array By link key + */ + private function getEdgeData(array $vars, string $field): array + { + return array_column(array_column($vars['elements']['edges'], 'data'), $field, 'id'); + } + + /** + * A field of the data of the groups given to Cytoscape.js. + * + * @param array $vars Template variables + * @return array By group id (or point id) + */ + private function getNodeData(array $vars, string $field): array + { + return array_column(array_column($vars['elements']['nodes'], 'data'), $field, 'id'); + } + + /** + * Applies the action as the page does: from the state of the previous render. + * + * @param array $params + */ + private function handle(int $entities_id, TreeEditor $editor, AbstractTreeAction $action, array $params = []): TreeEditor + { + // The page sends the preferences of the user with each change. + $vars = $editor->getTemplateVariables(); + $editor = TreeEditor::fromState($entities_id, $editor->getState(), [ + '_orientation' => $vars['orientation'], + '_drawing_strategy' => $vars['drawing_strategy'], + ]); + $editor->apply($action, $params); + + return $editor; + } + + /** + * A request of the page, with the draft of the given editor. + * + * @param array $params Action and its parameters + * @return array + */ + private function request(int $entities_id, TreeEditor $editor, array $params): array + { + return $params + ['entities_id' => $entities_id, 'state' => json_encode($editor->getState())]; + } + + public function testFromDatabase(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + $this->createLinks($this->getEntity($group), [[$ids['Current'], $ids['C'], LinkStrategyEnum::BASIC]]); + + $editor = TreeEditor::fromDatabase($this->getEntity($group)); + $this->assertSame([ + 'entities_id' => $this->getEntity($group), + 'version' => Group_Link::getVersion(Group_Link::getRowsOfEntity($this->getEntity($group))), + 'reset' => false, + 'nodes' => [$ids['Current'], $ids['C'], $ids['A'], $ids['B']], + 'links' => [['from' => $ids['Current'], 'to' => $ids['C'], 'type' => LinkStrategyEnum::BASIC->value]], + 'removed' => [], + 'selected_node' => null, + 'selected_link' => null, + ], $editor->getState()); + + $vars = $editor->getTemplateVariables(); + // Drawn with the color of their strategy + $this->assertSame( + [ + sprintf('%d-%d', $ids['Current'], $ids['C']) => LinkStrategyEnum::BASIC->getStrategy()->getColor(), + sprintf('%d-%d', $ids['A'], $ids['B']) => LinkStrategyEnum::INHERITED->getStrategy()->getColor(), + ], + $this->getEdgeData($vars, 'color'), + ); + $this->assertSame( + [sprintf('%d-%d', $ids['Current'], $ids['C']) => false, sprintf('%d-%d', $ids['A'], $ids['B']) => true], + $this->getEdgeData($vars, 'replicated'), + ); + // Only the strategies drawn can be chosen, the inherited one being replicated. + $this->assertSame( + [LinkStrategyEnum::BASIC->value, LinkStrategyEnum::INHERITED->value], + array_column($vars['strategies'], 'value'), + ); + $this->assertSame([false, true], array_column($vars['strategies'], 'replicable')); + $this->assertSame( + [LinkStrategyEnum::BASIC->getStrategy()->getColor(), LinkStrategyEnum::INHERITED->getStrategy()->getColor()], + array_column($vars['strategies'], 'color'), + ); + // Shown as a badge on the groups + $this->assertSame( + [$ids['Current'] => 1, $ids['C'] => 2, $ids['A'] => 1, $ids['B'] => 2], + $this->getNodeData($vars, 'level'), + ); + $this->assertNull($vars['selected_node']); + + // The selected group shows all its links, both ways. + $editor->apply(new SelectNodeAction(), ['group' => $ids['C']]); + $selected = $editor->getTemplateVariables()['selected_node']; + $this->assertSame('C', $selected['name']); + $this->assertSame(2, $selected['level']); + $this->assertSame([], $selected['to']); + $this->assertSame([sprintf('%d-%d', $ids['Current'], $ids['C'])], array_column($selected['from'], 'key')); + $this->assertSame(['Current'], array_column($selected['from'], 'from_name')); + + $editor->apply(new SelectNodeAction(), ['group' => $ids['A']]); + $selected = $editor->getTemplateVariables()['selected_node']; + $this->assertSame(['B'], array_column($selected['to'], 'to_name')); + $this->assertSame([true], array_column($selected['to'], 'replicated')); + $this->assertSame([LinkStrategyEnum::INHERITED->getStrategy()->getColor()], array_column($selected['to'], 'color')); + $this->assertSame([], $selected['from']); + + $pool = array_column($vars['pool'], 'id'); + $this->assertContains($ids['D'], $pool); + $this->assertNotContains($ids['A'], $pool); + } + + public function testGroupWithoutLinksIsNotPlaced(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + + // Without links, a group of the entity is not in the graph: only the groups of the replicated link. + $editor = TreeEditor::fromDatabase($this->getEntity($group)); + $this->assertSame([$ids['A'], $ids['B']], $editor->getState()['nodes']); + $this->assertContains($ids['Current'], array_column($editor->getTemplateVariables()['pool'], 'id')); + + // Once linked and saved, it is. + $editor = $this->handle($this->getEntity($group), $editor, new AddNodeAction(), ['group' => $ids['Current']]); + $editor = $this->handle($this->getEntity($group), $editor, new LinkAction(), ['from' => $ids['Current'], 'to' => $ids['A']]); + $this->assertTrue($editor->save()); + $this->assertContains($ids['Current'], TreeEditor::fromDatabase($this->getEntity($group))->getState()['nodes']); + } + + public function testLinksOfTheGivenEntity(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + + // In the child entity, only the inherited link of the root entity is replicated. + $vars = TreeEditor::fromDatabase($this->getEntity($group))->getTemplateVariables(); + $this->assertSame([sprintf('%d-%d', $ids['A'], $ids['B']) => true], $this->getEdgeData($vars, 'replicated')); + + // In the root entity, both links are its own. + $this->setEntity($this->getRootEntityId(), true); + $vars = TreeEditor::fromDatabase($this->getRootEntityId())->getTemplateVariables(); + $this->assertSame( + [sprintf('%d-%d', $ids['A'], $ids['B']) => false, sprintf('%d-%d', $ids['C'], $ids['D']) => false], + $this->getEdgeData($vars, 'replicated'), + ); + } + + public function testAddLinkFromTheGroupPanel(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + $editor = $this->handle($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), new AddNodeAction(), ['group' => $ids['Current']]); + $editor = $this->handle($this->getEntity($group), $editor, new SelectNodeAction(), ['group' => $ids['Current']]); + + // Only the group itself is left out of the group dropdowns: every other one can be linked. + $selected = $editor->getTemplateVariables()['selected_node']; + $others = array_diff(array_keys(Group_Link::getGroupsForEntity($this->getEntity($group))), [$ids['Current']]); + $this->assertEqualsCanonicalizing($others, array_keys($selected['candidates_to'])); + $this->assertEqualsCanonicalizing($others, array_keys($selected['candidates_from'])); + $this->assertSame('D', $selected['candidates_to'][$ids['D']]); + + // Current -> D, inherited: D is placed, Current stays selected. + $editor = $this->handle($this->getEntity($group), $editor, new AddLinkAction(), [ + 'direction' => 'to', + '_link_to_group' => $ids['D'], + '_link_to_strategy' => LinkStrategyEnum::INHERITED->value, + ]); + $this->assertSame([['from' => $ids['Current'], 'to' => $ids['D'], 'type' => LinkStrategyEnum::INHERITED->value]], $editor->getState()['links']); + $this->assertSame($ids['Current'], $editor->getState()['selected_node']); + $this->assertSame(['D'], array_column($editor->getTemplateVariables()['selected_node']['to'], 'to_name')); + + // A -> Current, basic (the default strategy) + $editor = $this->handle($this->getEntity($group), $editor, new AddLinkAction(), ['direction' => 'from', '_link_from_group' => $ids['A']]); + $this->assertSame(['A'], array_column($editor->getTemplateVariables()['selected_node']['from'], 'from_name')); + $this->assertSame(LinkStrategyEnum::BASIC->value, $editor->getState()['links'][1]['type']); + + // Deleting a link from the panel of the selected group keeps it selected. + $editor = $this->handle($this->getEntity($group), $editor, new AddLinkAction(), ['direction' => 'to', '_link_to_group' => $ids['B']]); + $editor = $this->handle($this->getEntity($group), $editor, new DeleteLinkAction(), ['link' => EscalationLink::key($ids['Current'], $ids['B'])]); + $this->assertSame($ids['Current'], $editor->getState()['selected_node']); + $this->assertCount(2, $editor->getState()['links']); + + // From D, neither Current nor A (its ancestors) are offered, and forcing them is refused. + $editor = $this->handle($this->getEntity($group), $editor, new SelectNodeAction(), ['group' => $ids['D']]); + $selected = $editor->getTemplateVariables()['selected_node']; + $this->assertArrayNotHasKey($ids['Current'], $selected['candidates_to']); + $this->assertArrayNotHasKey($ids['A'], $selected['candidates_to']); + $this->assertArrayNotHasKey($ids['D'], $selected['candidates_to']); + $this->assertArrayHasKey($ids['B'], $selected['candidates_to']); + // Current, already linked to D, is not offered either the other way. + $this->assertArrayNotHasKey($ids['Current'], $selected['candidates_from']); + $editor = $this->handle($this->getEntity($group), $editor, new AddLinkAction(), ['direction' => 'to', '_link_to_group' => $ids['A']]); + $this->assertIsString($editor->getTemplateVariables()['error']); + $this->assertCount(2, $editor->getState()['links']); + } + + public function testEditAndSave(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + $editor = $this->handle($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), new AddNodeAction(), ['group' => $ids['Current']]); + $current_c = EscalationLink::key($ids['Current'], $ids['C']); + $a_b = EscalationLink::key($ids['A'], $ids['B']); + + // Place D, then draw Current -> D, by dragging Current onto D + $editor = $this->handle($this->getEntity($group), $editor, new AddNodeAction(), ['group' => $ids['D']]); + $this->assertContains($ids['D'], $editor->getState()['nodes']); + $this->assertNull($editor->getState()['selected_node']); + $this->assertSame($ids['D'], $editor->getTemplateVariables()['scroll_to']); + $editor = $this->handle($this->getEntity($group), $editor, new LinkAction(), ['from' => $ids['Current'], 'to' => $ids['D']]); + $key = EscalationLink::key($ids['Current'], $ids['D']); + $this->assertSame($key, $editor->getState()['selected_link']); + $editor = $this->handle($this->getEntity($group), $editor, new SetLinkTypeAction(), ['value' => LinkStrategyEnum::INHERITED->value]); + + // Draw Current -> C, then delete it from the context menu, without selecting it + $editor = $this->handle($this->getEntity($group), $editor, new AddNodeAction(), ['group' => $ids['C']]); + $editor = $this->handle($this->getEntity($group), $editor, new LinkAction(), ['from' => $ids['Current'], 'to' => $ids['C']]); + $this->assertSame($current_c, $editor->getState()['selected_link']); + $editor = $this->handle($this->getEntity($group), $editor, new ClearAction()); + $editor = $this->handle($this->getEntity($group), $editor, new SetLinkTypeAction(), ['link' => $current_c, 'value' => LinkStrategyEnum::INHERITED->value]); + $this->assertSame([LinkStrategyEnum::INHERITED->value, LinkStrategyEnum::INHERITED->value], array_column($editor->getState()['links'], 'type')); + $editor = $this->handle($this->getEntity($group), $editor, new DeleteLinkAction(), ['link' => $current_c]); + + // D cannot escalate to Current, which escalates to it: the link is refused, with an explanation. + $editor = $this->handle($this->getEntity($group), $editor, new LinkAction(), ['from' => $ids['D'], 'to' => $ids['Current']]); + $this->assertIsString($editor->getTemplateVariables()['error']); + $this->assertCount(1, $editor->getState()['links']); + $editor = $this->handle($this->getEntity($group), $editor, new ClearAction()); + + // Remove the inherited link A -> B + $editor = $this->handle($this->getEntity($group), $editor, new SelectLinkAction(), ['link' => $a_b]); + + $selected = $editor->getTemplateVariables()['selected_link']; + $this->assertTrue($selected['replicated']); + $this->assertSame([$ids['A'], $ids['B']], [$selected['from_id'], $selected['to_id']]); + $this->assertSame(LinkStrategyEnum::INHERITED->getStrategy()->getColor(), $selected['color']); + $editor = $this->handle($this->getEntity($group), $editor, new DeleteLinkAction()); + + $state = $editor->getState(); + $this->assertSame([['from' => $ids['Current'], 'to' => $ids['D'], 'type' => LinkStrategyEnum::INHERITED->value]], $state['links']); + $this->assertSame([$a_b], $state['removed']); + + // Nothing is saved until the "save" action. + $this->assertSame([], $this->getEntityLinks($this->getEntity($group))); + $this->assertTrue(TreeEditor::fromState($this->getEntity($group), $state)->save()); + $this->assertSame([ + $a_b => LinkStrategyEnum::NONE->value, + $key => LinkStrategyEnum::INHERITED->value, + ], $this->getEntityLinks($this->getEntity($group))); + + // Once saved, the removed inherited link is loaded as removed, and drawing it again restores it. + $editor = TreeEditor::fromDatabase($this->getEntity($group)); + $this->assertSame([$a_b], $editor->getState()['removed']); + $editor = $this->handle($this->getEntity($group), $editor, new AddNodeAction(), ['group' => $ids['A']]); + $editor = $this->handle($this->getEntity($group), $editor, new AddNodeAction(), ['group' => $ids['B']]); + $editor = $this->handle($this->getEntity($group), $editor, new LinkAction(), ['from' => $ids['A'], 'to' => $ids['B']]); + $this->assertSame([], $editor->getState()['removed']); + + // Once saved, the restored link applies again: its "none" link is gone. + $this->assertTrue(TreeEditor::fromState($this->getEntity($group), $editor->getState())->save()); + $this->assertArrayNotHasKey($a_b, $this->getEntityLinks($this->getEntity($group))); + $this->assertSame([], TreeEditor::fromDatabase($this->getEntity($group))->getState()['removed']); + + // Removing a group removes its links. + $editor = $this->handle($this->getEntity($group), $editor, new RemoveNodeAction(), ['group' => $ids['D']]); + $this->assertSame([], $editor->getState()['links']); + $this->assertNotContains($ids['D'], $editor->getState()['nodes']); + } + + public function testFromStateIgnoresInvalidData(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + $root_id = $this->getRootEntityId(); + $not_assignable = $this->createGroups(['Not assignable'], $this->getEntity($group), assignable: false)['Not assignable']; + // Groups of other entities: a sibling one, and the parent one without recursion + $sibling = $this->createGroups(['Sibling group'], $this->createChildEntity('Sibling', $root_id))['Sibling group']; + $parent = $this->createGroups(['Parent group'], $root_id)['Parent group']; + + $editor = TreeEditor::fromState($this->getEntity($group), [ + 'nodes' => [$ids['A'], $ids['C'], $not_assignable, $sibling, $parent, 'foo'], + 'links' => [ + ['from' => $ids['A'], 'to' => $ids['C'], 'type' => LinkStrategyEnum::BASIC->value], + ['from' => $ids['A'], 'to' => $ids['A'], 'type' => LinkStrategyEnum::BASIC->value], + ['from' => $ids['C'], 'to' => $ids['A'], 'type' => LinkStrategyEnum::NONE->value], + ['from' => $ids['C'], 'to' => $not_assignable, 'type' => LinkStrategyEnum::BASIC->value], + ['from' => $ids['C'], 'to' => $sibling, 'type' => LinkStrategyEnum::BASIC->value], + ['from' => $parent, 'to' => $ids['A'], 'type' => LinkStrategyEnum::BASIC->value], + 'foo', + ], + 'removed' => [EscalationLink::key($ids['A'], $ids['C']), EscalationLink::key($ids['C'], $ids['D'])], + 'selected_link' => 'foo', + ]); + + $this->assertSame([ + 'entities_id' => $this->getEntity($group), + 'version' => '', + 'reset' => false, + 'nodes' => [$ids['A'], $ids['C']], + 'links' => [['from' => $ids['A'], 'to' => $ids['C'], 'type' => LinkStrategyEnum::BASIC->value]], + 'removed' => [], + 'selected_node' => null, + 'selected_link' => null, + ], $editor->getState()); + + // Saved, only the valid link is. + $this->assertTrue($editor->save()); + $this->assertSame( + [EscalationLink::key($ids['A'], $ids['C']) => LinkStrategyEnum::BASIC->value], + $this->getEntityLinks($this->getEntity($group)), + ); + } + + /** + * The draft of the editor without its selection: the tree only. + * + * @return array + */ + private function getTreeState(TreeEditor $editor): array + { + return array_diff_key($editor->getState(), ['selected_node' => true, 'selected_link' => true]); + } + + public function testReadOnly(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + // Without the right to update the configuration + $_SESSION['glpiactiveprofile']['config'] = READ; + + $editor = TreeEditor::fromDatabase($this->getEntity($group)); + $this->assertFalse($editor->getTemplateVariables()['canedit']); + $this->assertFalse($editor->getTemplateVariables()['can_reset']); + // Whatever the action changing the links, with all the parameters it could use + $editor->apply(new SelectNodeAction(), ['group' => $ids['A']]); + $state = $this->getTreeState($editor); + $params = [ + 'group' => $ids['A'], + 'link' => EscalationLink::key($ids['A'], $ids['B']), + 'from' => $ids['B'], + 'to' => $ids['C'], + 'value' => LinkStrategyEnum::INHERITED->value, + 'direction' => 'to', + '_link_to_group' => $ids['C'], + ]; + foreach (TreeActionRegistry::getActions() as $action) { + if ($action->requiresEdit()) { + $editor->apply($action, $params); + $this->assertSame($state, $this->getTreeState($editor), $action::getName()); + } + } + + // Selecting stays possible. + $editor->apply(new SelectLinkAction(), ['link' => EscalationLink::key($ids['A'], $ids['B'])]); + $this->assertSame(EscalationLink::key($ids['A'], $ids['B']), $editor->getState()['selected_link']); + } + + public function testDraftOfAnotherEntityIsRefused(): void + { + $this->login(); + [$group] = $this->createGraphData(); + $this->setEntity($this->getRootEntityId(), true); + $draft = TreeEditor::fromDatabase($this->getEntity($group)); + + // The draft is only applied to the entity it was made for. + $this->expectException(BadRequestHttpException::class); + TreeEditor::respond($this->request($this->getRootEntityId(), $draft, ['action' => RenderAction::getName()])); + } + + public function testOrientation(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + $editor = TreeEditor::fromDatabase($this->getEntity($group)); + $editor->apply(new OrientationAction(), ['value' => 'vertical']); + + $vars = $editor->getTemplateVariables(); + $this->assertSame('vertical', $vars['orientation']); + + // Vertically, A (level 1) is above B (level 2). + $positions = []; + foreach ($vars['elements']['nodes'] as $node) { + $positions[(int) $node['data']['id']] = $node['position']; + } + + $this->assertLessThan($positions[$ids['B']]['y'], $positions[$ids['A']]['y']); + + $editor->apply(new OrientationAction(), ['value' => 'diagonal']); + $this->assertSame('vertical', $editor->getTemplateVariables()['orientation']); + $editor->apply(new OrientationAction(), ['value' => 'horizontal']); + $this->assertSame('horizontal', $editor->getTemplateVariables()['orientation']); + } + + public function testPreferencesAreSentByThePage(): void + { + $this->login(); + [$group] = $this->createGraphData(); + $editor = TreeEditor::fromDatabase($this->getEntity($group)); + $this->assertSame('horizontal', $editor->getTemplateVariables()['orientation']); + $this->assertSame(LinkStrategyEnum::BASIC->value, $editor->getTemplateVariables()['drawing_strategy']); + + $vars = TreeEditor::respond($this->request($this->getEntity($group), $editor, [ + 'action' => 'render', + '_orientation' => 'vertical', + '_drawing_strategy' => LinkStrategyEnum::INHERITED->value, + ]))->getTemplateVariables(); + $this->assertSame('vertical', $vars['orientation']); + $this->assertSame(LinkStrategyEnum::INHERITED->value, $vars['drawing_strategy']); + + // Kept by the page only, and unknown values are ignored. + $vars = TreeEditor::respond($this->request($this->getEntity($group), $editor, [ + 'action' => 'render', + '_orientation' => 'diagonal', + '_drawing_strategy' => LinkStrategyEnum::NONE->value, + ]))->getTemplateVariables(); + $this->assertSame('horizontal', $vars['orientation']); + $this->assertSame(LinkStrategyEnum::BASIC->value, $vars['drawing_strategy']); + } + + public function testDrawingStrategy(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + $editor = $this->handle($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), new AddNodeAction(), ['group' => $ids['Current']]); + $this->assertSame(LinkStrategyEnum::BASIC->value, $editor->getTemplateVariables()['drawing_strategy']); + + // Links drawn take the strategy chosen above the graph; a strategy that is not drawn is ignored. + $editor = $this->handle($this->getEntity($group), $editor, new DrawingStrategyAction(), ['value' => LinkStrategyEnum::INHERITED->value]); + $editor = $this->handle($this->getEntity($group), $editor, new DrawingStrategyAction(), ['value' => LinkStrategyEnum::NONE->value]); + $this->assertSame(LinkStrategyEnum::INHERITED->value, $editor->getTemplateVariables()['drawing_strategy']); + $editor = $this->handle($this->getEntity($group), $editor, new AddNodeAction(), ['group' => $ids['C']]); + $editor = $this->handle($this->getEntity($group), $editor, new LinkAction(), ['from' => $ids['Current'], 'to' => $ids['C']]); + $this->assertSame([['from' => $ids['Current'], 'to' => $ids['C'], 'type' => LinkStrategyEnum::INHERITED->value]], $editor->getState()['links']); + + // A link already there keeps its strategy. + $editor = $this->handle($this->getEntity($group), $editor, new DrawingStrategyAction(), ['value' => LinkStrategyEnum::BASIC->value]); + $editor = $this->handle($this->getEntity($group), $editor, new LinkAction(), ['from' => $ids['Current'], 'to' => $ids['C']]); + $this->assertSame(LinkStrategyEnum::INHERITED->value, $editor->getState()['links'][0]['type']); + } + + public function testReset(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + $root_id = $this->getRootEntityId(); + // The root entity also passes down B -> C. + $this->createLinks($root_id, [[$ids['B'], $ids['C'], LinkStrategyEnum::INHERITED]]); + // In the child entity: a link of its own, the inherited A -> B removed, the inherited B -> C replaced. + $this->createLinks($this->getEntity($group), [ + [$ids['Current'], $ids['A'], LinkStrategyEnum::BASIC], + [$ids['A'], $ids['B'], LinkStrategyEnum::NONE], + [$ids['B'], $ids['C'], LinkStrategyEnum::BASIC], + ]); + $this->assertTrue(TreeEditor::fromDatabase($this->getEntity($group))->getTemplateVariables()['can_reset']); + + // Reset: the links of the entity are gone, the inherited ones are back, as they are in the root entity. + $editor = $this->handle($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), new ResetAction()); + $this->assertSame([], $editor->getState()['links']); + $this->assertSame([], $editor->getState()['removed']); + $this->assertNotContains($ids['Current'], $editor->getState()['nodes']); + $this->assertFalse($editor->getTemplateVariables()['can_reset']); + // Nothing is saved until the "save" action. + $this->assertCount(3, $this->getEntityLinks($this->getEntity($group))); + $this->assertTrue(TreeEditor::fromState($this->getEntity($group), $editor->getState())->save()); + $this->assertSame([], $this->getEntityLinks($this->getEntity($group))); + $this->assertSame( + [EscalationLink::key($ids['A'], $ids['B']) => true, EscalationLink::key($ids['B'], $ids['C']) => true], + $this->getEdgeData(TreeEditor::fromDatabase($this->getEntity($group))->getTemplateVariables(), 'replicated'), + ); + + // In the root entity, no link is left. + $this->setEntity($root_id, true); + $editor = $this->handle($root_id, TreeEditor::fromDatabase($root_id), new ResetAction()); + $this->assertTrue(TreeEditor::fromState($root_id, $editor->getState())->save()); + $this->assertSame([], $this->getEntityLinks($root_id)); + } + + public function testDisplay(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + $editor = TreeEditor::fromDatabase($this->getEntity($group)); + $editor->apply(new SelectLinkAction(), ['link' => EscalationLink::key($ids['A'], $ids['B'])]); + + $html = $this->render($editor); + + // The graph, drawn by Cytoscape.js from its elements + $this->assertStringContainsString('data-mo-cy', $html); + $this->assertStringContainsString('"id":"' . EscalationLink::key($ids['A'], $ids['B']) . '"', $html); + // The panel of the selected link, with its groups + $this->assertStringContainsString('data-mo-action="select_node" data-mo-group="' . $ids['A'] . '"', $html); + $this->assertStringContainsString('class="alert alert-info', $html); + $this->assertStringContainsString('mo-gl-edge-replicated', $html); + $this->assertStringContainsString('data-mo-action="delete_link"', $html); + + // The panel of a selected group can remove it from the graph. + $editor->apply(new SelectNodeAction(), ['group' => $ids['A']]); + $this->assertMatchesRegularExpression('/data-mo-action="remove_node"\s+data-mo-group="' . $ids['A'] . '"/', $this->render($editor)); + + // The group just placed is the one to bring into view. + $editor->apply(new AddNodeAction(), ['group' => $ids['D']]); + $this->assertStringContainsString('data-mo-scroll-to="' . $ids['D'] . '"', $this->render($editor)); + } + + public function testDeleteSelection(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + + // The selected link + $editor = $this->handle($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), new SelectLinkAction(), ['link' => EscalationLink::key($ids['A'], $ids['B'])]); + $editor = $this->handle($this->getEntity($group), $editor, new DeleteSelectionAction()); + $this->assertSame([EscalationLink::key($ids['A'], $ids['B'])], $editor->getState()['removed']); + $this->assertNull($editor->getState()['selected_link']); + + // The selected group + $editor = $this->handle($this->getEntity($group), $editor, new AddNodeAction(), ['group' => $ids['Current']]); + $editor = $this->handle($this->getEntity($group), $editor, new SelectNodeAction(), ['group' => $ids['Current']]); + $editor = $this->handle($this->getEntity($group), $editor, new DeleteSelectionAction()); + $this->assertNotContains($ids['Current'], $editor->getState()['nodes']); + + // Nothing selected: nothing to delete + $state = $editor->getState(); + $editor = $this->handle($this->getEntity($group), $editor, new DeleteSelectionAction()); + $this->assertSame($state, $editor->getState()); + } + + public function testRespond(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + + // A change, applied to the draft sent + $editor = TreeEditor::respond($this->request($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), ['action' => 'add_node', 'group' => $ids['Current']])); + $this->assertContains($ids['Current'], $editor->getState()['nodes']); + + // Saved, then reloaded + $editor = TreeEditor::respond($this->request($this->getEntity($group), $editor, ['action' => 'link', 'from' => $ids['Current'], 'to' => $ids['A']])); + $editor = TreeEditor::respond($this->request($this->getEntity($group), $editor, ['action' => 'save'])); + $this->assertSame( + [EscalationLink::key($ids['Current'], $ids['A']) => LinkStrategyEnum::BASIC->value], + $this->getEntityLinks($this->getEntity($group)), + ); + $this->assertSame(Group_Link::getVersion(Group_Link::getRowsOfEntity($this->getEntity($group))), $editor->getState()['version']); + + // A draft made before links were saved by someone else is not saved over them. + $outdated = $this->request($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), ['action' => 'save']); + $this->createLinks($this->getEntity($group), [[$ids['Current'], $ids['C'], LinkStrategyEnum::BASIC]]); + $editor = TreeEditor::respond($outdated); + $this->assertStringContainsString('saved by someone else', (string) $editor->getTemplateVariables()['error']); + $this->assertCount(2, $this->getEntityLinks($this->getEntity($group))); + } + + /** + * @return iterable, class-string}> + */ + public static function invalidRequestProvider(): iterable + { + yield 'unknown entity' => [['entities_id' => -1], AccessDeniedHttpException::class]; + yield 'unknown action' => [['action' => 'foo'], BadRequestHttpException::class]; + yield 'invalid draft' => [['state' => '{'], BadRequestHttpException::class]; + yield 'draft not an object' => [['state' => '"foo"'], BadRequestHttpException::class]; + // The page only sends single values. + foreach (['entities_id', 'action', 'state', 'query', 'group', 'link', 'from', 'value'] as $name) { + yield $name . ' not a single value' => [[$name => ['x']], BadRequestHttpException::class]; + } + } + + /** + * @param array $input + * @param class-string $exception + */ + #[DataProvider('invalidRequestProvider')] + public function testRespondRefusesInvalidRequests(array $input, string $exception): void + { + $this->login(); + [$group] = $this->createGraphData(); + + $this->expectException($exception); + TreeEditor::respond($input + $this->request($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), ['action' => RenderAction::getName()])); + } + + public function testEditingNeedsTheRightToUpdateTheConfiguration(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + // The groups can be updated, not the configuration. + $_SESSION['glpiactiveprofile']['group'] = ALLSTANDARDRIGHT; + $_SESSION['glpiactiveprofile']['config'] = READ; + $this->assertTrue(Group::canUpdate()); + $this->assertFalse(Config::canUpdate()); + + // The links cannot be changed... + $editor = TreeEditor::respond($this->request($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), ['action' => 'add_node', 'group' => $ids['Current']])); + $this->assertFalse($editor->getTemplateVariables()['canedit']); + $this->assertNotContains($ids['Current'], $editor->getState()['nodes']); + + // ...nor saved. + $this->expectException(AccessDeniedHttpException::class); + TreeEditor::respond($this->request($this->getEntity($group), $editor, ['action' => TreeEditor::SAVE_ACTION])); + } + + public function testEditingWithTheRightToUpdateTheConfiguration(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + // The configuration can be updated, the groups only read. + $_SESSION['glpiactiveprofile']['group'] = READ; + $_SESSION['glpiactiveprofile']['config'] = READ | UPDATE; + $this->assertFalse(Group::canUpdate()); + + $editor = TreeEditor::respond($this->request($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), ['action' => 'add_node', 'group' => $ids['Current']])); + $this->assertTrue($editor->getTemplateVariables()['canedit']); + $editor = TreeEditor::respond($this->request($this->getEntity($group), $editor, ['action' => 'link', 'from' => $ids['Current'], 'to' => $ids['A']])); + TreeEditor::respond($this->request($this->getEntity($group), $editor, ['action' => 'save'])); + $this->assertSame( + [EscalationLink::key($ids['Current'], $ids['A']) => LinkStrategyEnum::BASIC->value], + $this->getEntityLinks($this->getEntity($group)), + ); + } + + public function testNamesAreEscaped(): void + { + $this->login(); + [$group] = $this->createGraphData(); + $name = ''; + $evil = $this->createGroups([$name], $this->getEntity($group))[$name]; + + // In the graph, then in the panel of the group + $editor = $this->handle($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), new AddNodeAction(), ['group' => $evil]); + foreach ([$editor, $this->handle($this->getEntity($group), $editor, new SelectNodeAction(), ['group' => $evil])] as $shown) { + $html = $this->render($shown); + $this->assertStringNotContainsString('assertStringNotContainsString('login(); + [$group, $ids] = $this->createGraphData(); + $editor = $this->handle($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), new AddNodeAction(), ['group' => $ids['Current']]); + + // By level, then by name, with the search applied + $placed = $editor->getTemplateVariables()['placed']; + $this->assertSame([$ids['A'], $ids['Current'], $ids['B']], array_column($placed, 'id')); + $this->assertSame([1, 1, 2], array_column($placed, 'level')); + $editor = $this->handle($this->getEntity($group), $editor, new RenderAction(), ['query' => 'cur']); + $this->assertSame([false, true, false], array_column($editor->getTemplateVariables()['placed'], 'matches')); + + // Each one selects its group, without the right to edit too. + $_SESSION['glpiactiveprofile']['config'] = READ; + $html = $this->render(TreeEditor::fromDatabase($this->getEntity($group))); + $this->assertStringContainsString('data-mo-action="select_node" data-mo-group="' . $ids['A'] . '"', $html); + $this->assertStringNotContainsString('data-mo-action="add_node"', $html); + } + + public function testActionsOfOtherPlugins(): void + { + $this->login(); + [$group, $ids] = $this->createGraphData(); + // The names sent by the page (see public/js/escalation_graph.js and the templates) + $this->assertSame( + ['render', 'clear', 'select_node', 'select_link', 'orientation', 'drawing_strategy', 'add_node', 'remove_node', + 'link', 'add_link', 'set_link_type', 'delete_link', 'delete_selection', 'reset'], + array_keys(TreeActionRegistry::getActions()), + ); + $this->assertNull(TreeActionRegistry::get('foo')); + + // Another plugin adds an action, sent by the page as the others. + TreeActionRegistry::register(new class extends AbstractTreeAction { + public static function getName(): string + { + return 'test_place_all'; + } + + public function apply(TreeEditor $editor, array $params): void + { + foreach (array_keys($editor->getTree()->getUnplacedGroups()) as $id) { + $editor->getTree()->addNode($id); + } + } + }); + $editor = TreeEditor::respond($this->request($this->getEntity($group), TreeEditor::fromDatabase($this->getEntity($group)), ['action' => 'test_place_all'])); + $this->assertContains($ids['Current'], $editor->getState()['nodes']); + $this->assertContains($ids['D'], $editor->getState()['nodes']); + } +} diff --git a/tests/Units/TreeLayoutTest.php b/tests/Units/TreeLayoutTest.php new file mode 100644 index 0000000..8ee0e39 --- /dev/null +++ b/tests/Units/TreeLayoutTest.php @@ -0,0 +1,207 @@ + + */ + public static function orientationProvider(): iterable + { + yield 'horizontal' => [false]; + yield 'vertical' => [true]; + } + + /** + * A tree of the root entity, with the given groups linked. + * + * @param list $names + * @param list $links Source and destination names of each link + * @return array{EscalationTree, array} The tree, and the ids of the groups, by name + */ + private function createTree(array $names, array $links): array + { + $entities_id = $this->getRootEntityId(); + $ids = $this->createGroups($names, $entities_id); + $tree = EscalationTree::load($entities_id, array_values($ids)); + foreach ($links as [$from, $to]) { + $this->assertNotNull($tree->link($ids[$from], $ids[$to])); + } + + return [$tree, $ids]; + } + + /** + * Points of a route, from its start to its end. + * + * @param array{from: array{x: int, y: int}, to: array{x: int, y: int}, points: list} $route + * @return list + */ + private function getPath(array $route): array + { + return [$route['from'], ...$route['points'], $route['to']]; + } + + /** + * A1 and A2 (level 1) escalate to B (level 2), which escalates to C (level 3); A1 also escalates + * to C directly, skipping a level. + */ + #[DataProvider('orientationProvider')] + public function testRoutesAvoidGroupsAndEachOther(bool $vertical): void + { + $this->login(); + [$tree, $ids] = $this->createTree(['A1', 'A2', 'B', 'C'], [['A1', 'B'], ['A2', 'B'], ['B', 'C'], ['A1', 'C']]); + + $layout = new TreeLayout($tree, $tree->getLevels(), $vertical); + $routes = $layout->getRoutes($tree->getLinks()); + + // Each route goes from the middle of the output side of its source to the input side of its + // target: at its middle, or next to it not to overlap another link. + [$primary, $secondary] = $vertical ? ['y', 'x'] : ['x', 'y']; + $half = intdiv($vertical ? TreeLayout::NODE_HEIGHT : TreeLayout::NODE_WIDTH, 2); + foreach ($tree->getLinks() as $key => $link) { + $source = $tree->getNode($link->source); + $target = $tree->getNode($link->destination); + $this->assertNotNull($source); + $this->assertNotNull($target); + $out = $layout->getPosition($source); + $out[$primary] += $half; + $this->assertSame($out, $routes[$key]['from']); + $in = $layout->getPosition($target); + $this->assertSame($in[$primary] - $half, $routes[$key]['to'][$primary]); + $this->assertContains($routes[$key]['to'][$secondary] - $in[$secondary], [0, TreeLayout::ARRIVAL_SHIFT]); + } + + $this->assertNoOverlap($tree, $routes); + // The link skipping a level goes around the group in between. + $this->assertCount(4, $routes[EscalationLink::key($ids['A1'], $ids['C'])]['points']); + + // No part of any route crosses a group. + $boxes = []; + foreach ($tree->getNodes() as $node) { + ['x' => $x, 'y' => $y] = $layout->getPosition($node); + $boxes[$node->id] = [$x - TreeLayout::NODE_WIDTH / 2, $y - TreeLayout::NODE_HEIGHT / 2, $x + TreeLayout::NODE_WIDTH / 2, $y + TreeLayout::NODE_HEIGHT / 2]; + } + + foreach ($routes as $key => $route) { + $path = $this->getPath($route); + for ($i = 0; $i < count($path) - 1; $i++) { + for ($t = 0.05; $t < 1; $t += 0.05) { + $x = $path[$i]['x'] + ($path[$i + 1]['x'] - $path[$i]['x']) * $t; + $y = $path[$i]['y'] + ($path[$i + 1]['y'] - $path[$i]['y']) * $t; + foreach ($boxes as $id => [$x1, $y1, $x2, $y2]) { + $this->assertFalse( + $x > $x1 && $x < $x2 && $y > $y1 && $y < $y2, + sprintf('Link %s crosses group %d', $key, $id), + ); + } + } + } + } + + // Links turning in the corridor after level 1 each have their own track in it. + $this->assertNotSame( + $routes[EscalationLink::key($ids['A2'], $ids['B'])]['points'][0][$primary], + $routes[EscalationLink::key($ids['A1'], $ids['C'])]['points'][0][$primary], + ); + } + + /** + * A (level 1, first row) escalates to D (level 2, second row), B (level 1, second row) to C + * (level 2, first row): the links cross in the corridor between both levels. + */ + #[DataProvider('orientationProvider')] + public function testCrossingLinksDoNotOverlap(bool $vertical): void + { + $this->login(); + [$tree] = $this->createTree(['A', 'B', 'C', 'D'], [['A', 'D'], ['B', 'C']]); + + $layout = new TreeLayout($tree, $tree->getLevels(), $vertical); + $this->assertNoOverlap($tree, $layout->getRoutes($tree->getLinks())); + } + + /** + * No two links share a part of their route, unless they leave the same group or arrive at the + * same group: the route of a link would read as going elsewhere. + * + * @param array}> $routes + */ + private function assertNoOverlap(EscalationTree $tree, array $routes): void + { + $segments = []; + foreach ($routes as $key => $route) { + $path = $this->getPath($route); + for ($i = 0; $i < count($path) - 1; $i++) { + $segments[$key][] = [$path[$i], $path[$i + 1]]; + } + } + + $links = $tree->getLinks(); + foreach ($segments as $key => $own) { + foreach ($segments as $other_key => $others) { + if ( + $key >= $other_key + || $links[$key]->source === $links[$other_key]->source + || $links[$key]->destination === $links[$other_key]->destination + ) { + continue; + } + + foreach ($own as [$a1, $a2]) { + foreach ($others as [$b1, $b2]) { + foreach (['x' => 'y', 'y' => 'x'] as $fixed => $along) { + if ($a1[$fixed] !== $a2[$fixed] || $b1[$fixed] !== $b2[$fixed] || $a1[$fixed] !== $b1[$fixed]) { + continue; + } + + $shared = min(max($a1[$along], $a2[$along]), max($b1[$along], $b2[$along])) + - max(min($a1[$along], $a2[$along]), min($b1[$along], $b2[$along])); + $this->assertLessThanOrEqual(0, $shared, sprintf('Links %s and %s overlap', $key, $other_key)); + } + } + } + } + } + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 308e3a6..e7453d6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -35,6 +35,7 @@ // Not the plugin `vendor/autoload.php`: it would load the dev tools dependencies (e.g. an older // `twig/twig`) over the GLPI core ones. require_once __DIR__ . '/MoreOptionsTestCase.php'; +require_once __DIR__ . '/EscalationTestCase.php'; if (!Plugin::isPluginActive('moreoptions')) { throw new RuntimeException('Plugin moreoptions is not active in the test database');