From 90e36f8dac8d708ce0c12ccf4c29ef8d8f9af2ac Mon Sep 17 00:00:00 2001 From: Lainow Date: Wed, 23 Sep 2026 17:28:15 +0200 Subject: [PATCH 1/5] 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 2fffad0e1fe55789ff9c055f0571e647fc07dcf9 Mon Sep 17 00:00:00 2001 From: Lainow Date: Mon, 28 Sep 2026 09:45:02 +0200 Subject: [PATCH 2/5] Fix lints --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd937f..e3317e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,3 +16,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 a4e20ac9832eaca8b769fd9ef25d446c6be55bb1 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/5] 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 e3317e0..7f36e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,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 664cd47..765379a 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -51,6 +51,7 @@ use CommonITILValidation; use Glpi\Application\View\TemplateRenderer; use GlpiPlugin\Moreoptions\Config; +use GlpiPlugin\Moreoptions\Escalation; use Group_Item; use Group_Problem; use Group_Ticket; @@ -489,7 +490,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 6bdcd7bcd20003ad72b0bd5ebb3f9f0c3a0cae83 Mon Sep 17 00:00:00 2001 From: Lainow Date: Mon, 28 Sep 2026 14:49:24 +0200 Subject: [PATCH 4/5] 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 31b145fdf60fa25bbf09cf2ec7032f8ace8b521d 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/5] 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 9ced93a..9019c5b 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 @@ +