Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

36 changes: 33 additions & 3 deletions public/css/moreoptions.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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
// "<icon> | <source groups> -> <target group>" summary next to the "Created: ... by ..." badge,
// then the escalation comment, which can be collapsed.
.timeline-item.moreoptions-escalation {
Expand All @@ -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
Expand All @@ -175,6 +175,10 @@
}
}

&.private-item .content-part .timeline-content {
border-style: dashed;
}

.timeline-header {
flex-wrap: wrap;
align-items: center;
Expand Down Expand Up @@ -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;
}
}
160 changes: 123 additions & 37 deletions src/Escalation.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,20 +113,8 @@ public static function showInTimeline(array $params): void

/** @var array<string, mixed> $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',
Expand All @@ -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<int, array<string, mixed>>
*/
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<int>
*/
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 "<icon> | <source groups> -> <target group>"
* (see escalation_timeline.html.twig, which puts it next to the "Created: ... by ..." badge),
Expand Down Expand Up @@ -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<string, mixed> $input
* @return array<string, mixed>|false
Expand All @@ -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;
Expand Down Expand Up @@ -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<int, array{id: int, date: string, author: string, sources: array<string>, 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
Expand Down
Loading
Loading