diff --git a/CHANGELOG.md b/CHANGELOG.md index 970ec5f..9019c5b 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] + 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. *