diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 71c61da..ac8dad1 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -20,7 +20,7 @@ jobs: name: "Generate CI matrix" uses: "glpi-project/plugin-ci-workflows/.github/workflows/generate-ci-matrix.yml@v1" with: - glpi-version: "11.0.x" + glpi-version: "12.0.x" ci: name: "GLPI ${{ matrix.glpi-version }} - php:${{ matrix.php-version }} - ${{ matrix.db-image }}" needs: "generate-ci-matrix" diff --git a/CHANGELOG.md b/CHANGELOG.md index 14ec683..ed04d00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [unreleased] +- GLPI 12 Compatibility + ### Fixed - Fixed an issue where a group could be added to a ticket even though it did not have the necessary permissions diff --git a/rector.php b/rector.php index 3bded1f..e4ee1e4 100644 --- a/rector.php +++ b/rector.php @@ -36,6 +36,7 @@ require_once __DIR__ . '/../../src/Plugin.php'; $baseline_file = __DIR__ . '/../../PluginsRector.php'; + if (!file_exists($baseline_file)) { throw new RuntimeException( sprintf( diff --git a/setup.php b/setup.php index bd1a412..bae2ec6 100644 --- a/setup.php +++ b/setup.php @@ -42,11 +42,11 @@ // Minimal GLPI version, inclusive /** @phpstan-ignore theCodingMachineSafe.function (safe to assume this isn't already defined) */ -define("PLUGIN_MOREOPTIONS_MIN_GLPI_VERSION", "11.0.0"); +define("PLUGIN_MOREOPTIONS_MIN_GLPI_VERSION", "12.0.0"); // Maximum GLPI version, exclusive /** @phpstan-ignore theCodingMachineSafe.function (safe to assume this isn't already defined) */ -define("PLUGIN_MOREOPTIONS_MAX_GLPI_VERSION", "11.0.99"); +define("PLUGIN_MOREOPTIONS_MAX_GLPI_VERSION", "12.0.99"); /** * Init hooks of the plugin. diff --git a/src/Config.php b/src/Config.php index b473975..2371c3f 100644 --- a/src/Config.php +++ b/src/Config.php @@ -47,9 +47,9 @@ class Config extends CommonDBTM { - public $dohistory = true; + public bool $dohistory = true; - public static $rightname = 'config'; + public static string $rightname = 'config'; public const CONFIG_PARENT = Entity::CONFIG_PARENT; @@ -75,12 +75,12 @@ public function defineTabs($options = []): array public static function canView(): bool { - return (bool) Session::haveRight(self::$rightname, READ); + return Session::haveRight(self::$rightname, READ); } public function canEdit($ID): bool { - return (bool) Session::haveRight(self::$rightname, UPDATE); + return Session::haveRight(self::$rightname, UPDATE); } public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0): string @@ -89,7 +89,7 @@ public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0): string return ''; } - if ($item->getType() === Entity::class) { + if ($item::class === Entity::class) { return self::createTabEntry(__('More options', 'moreoptions'), 0); } @@ -103,10 +103,8 @@ public static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $ return false; } - if ($item->getType() === Entity::class) { - if ($item instanceof Entity) { - self::showForEntity($item); - } + if ($item::class === Entity::class) { + self::showForEntity($item); return true; } @@ -515,9 +513,7 @@ public static function addConfig(CommonDBTM $item): void */ public static function getConfig(?int $entityId = null, bool $useInheritance = true): self { - if ($entityId === null) { - $entityId = Session::getActiveEntity(); - } + $entityId ??= Session::getActiveEntity(); $moconfig = new self(); $moconfig->getFromDBByCrit([ diff --git a/src/Controller.php b/src/Controller.php index 1e9490b..1d0e3ee 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -71,9 +71,9 @@ class Controller extends CommonDBTM { - public $dohistory = true; + public bool $dohistory = true; - public static $rightname = 'config'; + public static string $rightname = 'config'; private static bool $solution_check_done = false; @@ -563,7 +563,7 @@ public static function showSolutionRequirementsWarning(array $params): void ); TemplateRenderer::getInstance()->display('@moreoptions/timeline_solution_warning.html.twig', [ - 'marker_id' => 'moreoptions-solution-warning-' . $item->getType() . '-' . $item->getID(), + 'marker_id' => 'moreoptions-solution-warning-' . $item::class . '-' . $item->getID(), 'header' => $header, 'missing_fields' => $missing, ]); @@ -610,12 +610,11 @@ public static function checkTaskRequirements(CommonDBTM $item): CommonDBTM /** * Hooked on {@link \Glpi\Plugin\Hooks::POST_ITEM_FORM}. Renders, into the task creation/edit * form, a script that marks the fields configured as mandatory in moreoptions (category, - * duration, technician, technician group) with the usual red "required" marker and blocks - * client-side submission of the form until they are filled. + * duration, technician, technician group) with the usual red "required" marker. * - * The server-side block already performed by {@see self::checkTaskRequirements()} on actual - * submission (PRE_ITEM_ADD) is kept as-is; this only prevents the user from submitting an - * incomplete task in the first place. + * This is purely a visual hint: the actual block on submission is performed server-side by + * {@see self::checkTaskRequirements()} (PRE_ITEM_ADD), whose message is what gets shown to + * the user. * * @param array $params */ @@ -655,9 +654,8 @@ public static function markMandatoryTaskFields(array $params): void TemplateRenderer::getInstance()->display('@moreoptions/timeline_task_mandatory_fields.html.twig', [ // Unique per-call anchor: lets the injected script find its own
reliably. - 'marker_id' => 'moreoptions-task-mandatory-' . bin2hex(random_bytes(6)), - 'field_labels' => $labels, - 'error_message' => __('To create this task, you must fill in the following fields:', 'moreoptions'), + 'marker_id' => 'moreoptions-task-mandatory-' . bin2hex(random_bytes(6)), + 'field_labels' => $labels, ]); } diff --git a/templates/timeline_solution_warning.html.twig b/templates/timeline_solution_warning.html.twig index 2c20063..4872542 100644 --- a/templates/timeline_solution_warning.html.twig +++ b/templates/timeline_solution_warning.html.twig @@ -101,14 +101,20 @@ // A tooltip (not a popover) so it keeps GLPI's dark bubble style: core deliberately // styles popovers with a light background (see `.popover .popover-body` in // _base.scss), while tooltips stay dark. Tooltips support HTML content too. - solutionToggle.setAttribute('data-bs-toggle', 'tooltip'); - solutionToggle.setAttribute('data-bs-placement', 'right'); - solutionToggle.setAttribute('data-bs-trigger', 'hover focus'); - solutionToggle.setAttribute('data-bs-html', 'true'); - solutionToggle.setAttribute('title', {{ tooltip_content|json_encode|raw }}); - - if (typeof initTooltips === 'function') { - initTooltips(footer); + // + // The toggle already carries `data-bs-toggle="collapse"` (it's still a working + // "Add a solution" action): it can't also be set to "tooltip", as Bootstrap only + // honors one `data-bs-toggle` value per element and would silently drop the + // collapse behavior. The tooltip is instantiated directly through the JS API + // instead of going through `initTooltips()`'s `[data-bs-toggle="tooltip"]` selector. + if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) { + new bootstrap.Tooltip(solutionToggle, { + delay: {show: 50, hide: 50}, + html: true, + placement: 'right', + trigger: 'hover focus', + title: {{ tooltip_content|json_encode|raw }}, + }); } })(); diff --git a/templates/timeline_task_mandatory_fields.html.twig b/templates/timeline_task_mandatory_fields.html.twig index fe1b43f..81cdedb 100644 --- a/templates/timeline_task_mandatory_fields.html.twig +++ b/templates/timeline_task_mandatory_fields.html.twig @@ -32,24 +32,25 @@ {# # Rendered through the core `Hooks::POST_ITEM_FORM` hook (see Controller::markMandatoryTaskFields), # right before the task form's closing tag. Marks the configured fields as required (red asterisk - # on their label, matching GLPI's own convention) and blocks client-side submission until they - # are filled. + # on their label, matching GLPI's own convention). + # + # This is purely a visual hint: submission itself is left to go through normally so the existing + # server-side block ({@see Controller::checkTaskRequirements()}, on PRE_ITEM_ADD) can render its + # usual accessible, styled message. An earlier version of this script also intercepted the + # form's `submit` event to reject it client-side through a `window.alert()`: that stopped the + # AJAX round-trip entirely, so the styled server-side message never got a chance to show, and a + # native `alert()` popup isn't reachable through accessible/role-based locators anyway. #} diff --git a/tests/MoreOptionsTestCase.php b/tests/MoreOptionsTestCase.php index 05d79ed..b5de72b 100644 --- a/tests/MoreOptionsTestCase.php +++ b/tests/MoreOptionsTestCase.php @@ -127,8 +127,8 @@ protected function updateTestConfig(Config $config, array $updates): bool protected function getCurrentConfig(): Config { $config = Config::getConfig(); - if (empty($config->fields) || $config->isNewItem()) { - $config = $this->createTestConfig(); + if ($config->fields === [] || $config->isNewItem()) { + return $this->createTestConfig(); } return $config; @@ -171,12 +171,8 @@ protected function clearLogEntriesContaining(string $pattern): void */ protected function initEntitySession(): void { - if (!isset($_SESSION['glpiactiveentities_string'])) { - $_SESSION['glpiactiveentities_string'] = ''; - } + $_SESSION['glpiactiveentities_string'] ??= ''; - if (!isset($_SESSION['glpiactiveentities'])) { - $_SESSION['glpiactiveentities'] = []; - } + $_SESSION['glpiactiveentities'] ??= []; } } diff --git a/tests/e2e/fixtures/moreoptions_fixture.ts b/tests/e2e/fixtures/moreoptions_fixture.ts new file mode 100644 index 0000000..cc81830 --- /dev/null +++ b/tests/e2e/fixtures/moreoptions_fixture.ts @@ -0,0 +1,75 @@ +/** + * ------------------------------------------------------------------------- + * 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 + * ------------------------------------------------------------------------- + */ + +import { randomUUID } from 'crypto'; +import { Page } from '@playwright/test'; +// eslint-disable-next-line playwright/no-raw-locators +import { Api } from '../../../../../tests/e2e/utils/Api'; +// eslint-disable-next-line playwright/no-raw-locators +import { EntitySwitcher } from '../../../../../tests/e2e/utils/EntitySwitcher'; +// eslint-disable-next-line playwright/no-raw-locators +import { getWorkerEntityId } from '../../../../../tests/e2e/utils/WorkerEntities'; +import { MoreOptionsConfigPage } from '../pages/MoreOptionsConfigPage'; + +// Re-export the core GLPI fixture so plugin specs share the same authenticated +// `test`/`expect` and the worker/profile/api helpers. +export * from '../../../../../tests/e2e/fixtures/glpi_fixture'; + +/** + * Create a sub-entity of the worker's entity, switch to it and apply the + * given moreoptions settings to it. + * + * Each test gets its own entity: the settings it turns on block closures or + * task creations for everything in scope, which would otherwise interfere + * with unrelated specs sharing the worker's entity. + */ +export async function createConfiguredEntity( + page: Page, + api: Api, + entity: EntitySwitcher, + config: Record, +): Promise { + const entities_id = await api.createItem('Entity', { + 'name': `MoreOptions ${Object.keys(config).join(' ')} ${randomUUID()}`, + 'entities_id': getWorkerEntityId(), + }); + // A freshly created entity is not immediately usable otherwise: its + // recursive rights are only picked up on the next explicit entity switch. + await entity.switchToWithRecursion(entities_id); + // The `api` fixture caches one authenticated session per worker for its + // whole lifetime: without a refresh, it never sees an entity created + // after that session's first request. + api.refreshSession(); + + await new MoreOptionsConfigPage(page).doSetConfig(entities_id, config); + + return entities_id; +} diff --git a/tests/e2e/pages/MoreOptionsConfigPage.ts b/tests/e2e/pages/MoreOptionsConfigPage.ts new file mode 100644 index 0000000..c455585 --- /dev/null +++ b/tests/e2e/pages/MoreOptionsConfigPage.ts @@ -0,0 +1,110 @@ +/** + * ------------------------------------------------------------------------- + * 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 + * ------------------------------------------------------------------------- + */ + +import { expect, Locator, Page } from '@playwright/test'; +// eslint-disable-next-line playwright/no-raw-locators +import { GlpiPage } from '../../../../../tests/e2e/pages/GlpiPage'; + +// The plugin registers a single tab (index 1) on the Entity item. +const CONFIG_TAB = 'GlpiPlugin\\Moreoptions\\Config$1'; + +// Entity::CONFIG_PARENT: the "Inherit" choice, only offered below the root entity. +export const INHERIT = -2; +export const NO = 0; +export const YES = 1; + +export class MoreOptionsConfigPage extends GlpiPage +{ + public readonly save_button: Locator; + + public constructor(page: Page) + { + super(page); + + this.save_button = this.getButton('Save'); + } + + public async goto(entities_id: number): Promise + { + await this.page.goto( + `/front/entity.form.php?id=${entities_id}&forcetab=${CONFIG_TAB}` + ); + } + + /** + * Each setting is a group of radio inputs sharing the field name (see + * templates/components/segmented_control.html.twig). The inputs are + * visually hidden: they are toggled through their adjacent