From 2a3a0ea738792845a82c082923a8cf1c9100baec Mon Sep 17 00:00:00 2001 From: AdrienClairembault Date: Fri, 27 Dec 2024 17:07:12 +0100 Subject: [PATCH] Configure helpdesk home per profile --- css/includes/_base.scss | 5 + css/includes/components/_utils.scss | 12 + .../update_10.0.x_to_11.0.0/form.php | 9 +- install/mysql/glpi-empty.sql | 6 +- .../Helpdesk/HelpdeskConfigController.js | 263 ++++++++++++++++++ .../Glpi/Helpdesk/DefaultDataManagerTest.php | 3 + .../Glpi/Helpdesk/Tile/TilesManagerTest.php | 168 +++++++++++ .../Config/Helpdesk/DeleteTileController.php | 99 +++++++ .../Config/Helpdesk/FetchTilesController.php | 81 ++++++ .../Helpdesk/SetTilesOrderController.php | 89 ++++++ src/Glpi/Helpdesk/Tile/ExternalPageTile.php | 14 + src/Glpi/Helpdesk/Tile/FormTile.php | 13 + src/Glpi/Helpdesk/Tile/GlpiPageTile.php | 14 + src/Glpi/Helpdesk/Tile/TileInterface.php | 3 + src/Glpi/Helpdesk/Tile/TilesManager.php | 101 ++++++- src/Profile.php | 104 +++---- .../admin/helpdesk_home_config.html.twig | 82 ++++++ .../helpdesk_home_config_tiles.html.twig | 94 +++++++ .../e2e/self-service/home_config.cy.js | 208 ++++++++++++++ tests/cypress/support/commands.d.ts | 2 +- tests/cypress/support/commands/form.d.ts | 2 +- tests/cypress/support/commands/select2.d.ts | 2 +- 22 files changed, 1311 insertions(+), 63 deletions(-) create mode 100644 js/modules/Helpdesk/HelpdeskConfigController.js create mode 100644 src/Glpi/Controller/Config/Helpdesk/DeleteTileController.php create mode 100644 src/Glpi/Controller/Config/Helpdesk/FetchTilesController.php create mode 100644 src/Glpi/Controller/Config/Helpdesk/SetTilesOrderController.php create mode 100644 templates/pages/admin/helpdesk_home_config.html.twig create mode 100644 templates/pages/admin/helpdesk_home_config_tiles.html.twig create mode 100644 tests/cypress/e2e/self-service/home_config.cy.js diff --git a/css/includes/_base.scss b/css/includes/_base.scss index 6ca06545c48a..62d45580e6e1 100644 --- a/css/includes/_base.scss +++ b/css/includes/_base.scss @@ -469,3 +469,8 @@ body pre { .accordion-button:hover, .accordion-button:focus { z-index: unset; } + +// The definition from tabler doesn't seem to take into account our primary color. Bug? +.border-primary { + border-color: var(--tblr-primary) !important; +} diff --git a/css/includes/components/_utils.scss b/css/includes/components/_utils.scss index c67a43e0a516..f019ba421d8f 100644 --- a/css/includes/components/_utils.scss +++ b/css/includes/components/_utils.scss @@ -59,3 +59,15 @@ width: 100vw; margin-left: calc(50% - 50vw); } + +.border-dashed { + border-style: dashed !important; +} + +// Alternative to d-flex, does not includes "!important". +// This is needed when dealing with libraries that will attempt to hide an item +// by changing its style to "display: none" (like html5sortable), which will fail +// if the current property is marked as "!important". +.d-flex-soft { + display: flex; +} diff --git a/install/migrations/update_10.0.x_to_11.0.0/form.php b/install/migrations/update_10.0.x_to_11.0.0/form.php index 74190248fdf4..f96d6e7a507f 100644 --- a/install/migrations/update_10.0.x_to_11.0.0/form.php +++ b/install/migrations/update_10.0.x_to_11.0.0/form.php @@ -221,9 +221,11 @@ `profiles_id` int unsigned NOT NULL DEFAULT '0', `itemtype` varchar(255) DEFAULT NULL, `items_id` int unsigned NOT NULL DEFAULT '0', + `rank` int NOT NULL DEFAULT 0, PRIMARY KEY (`id`), - KEY `profiles_id` (`profiles_id`), - KEY `item` (`itemtype`,`items_id`) + UNIQUE KEY `unicity` (`profiles_id`, `rank`), + KEY `item` (`itemtype`,`items_id`), + KEY `rank` (`rank`) ) ENGINE=InnoDB DEFAULT CHARSET={$default_charset} COLLATE={$default_collation} ROW_FORMAT=DYNAMIC;" ); } @@ -329,6 +331,9 @@ $migration->addField("glpi_forms_comments", "forms_sections_uuid", "string"); $migration->addKey("glpi_forms_comments", "uuid", type: 'UNIQUE'); $migration->addKey("glpi_forms_comments", "forms_sections_uuid"); + + $migration->addField("glpi_helpdesks_tiles_profiles_tiles", "rank", "int"); + $migration->addKey("glpi_helpdesks_tiles_profiles_tiles", "rank"); } CronTask::register('Glpi\Form\Form', 'purgedraftforms', DAY_TIMESTAMP, [ diff --git a/install/mysql/glpi-empty.sql b/install/mysql/glpi-empty.sql index d1584ff9b114..f346b5239347 100644 --- a/install/mysql/glpi-empty.sql +++ b/install/mysql/glpi-empty.sql @@ -3167,9 +3167,11 @@ CREATE TABLE `glpi_helpdesks_tiles_profiles_tiles` ( `profiles_id` int unsigned NOT NULL DEFAULT '0', `itemtype` varchar(255) DEFAULT NULL, `items_id` int unsigned NOT NULL DEFAULT '0', + `rank` int NOT NULL DEFAULT 0, PRIMARY KEY (`id`), - KEY `profiles_id` (`profiles_id`), - KEY `item` (`itemtype`,`items_id`) + UNIQUE KEY `unicity` (`profiles_id`, `rank`), + KEY `item` (`itemtype`,`items_id`), + KEY `rank` (`rank`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; ### Dump table glpi_helpdesks_tiles_formtiles diff --git a/js/modules/Helpdesk/HelpdeskConfigController.js b/js/modules/Helpdesk/HelpdeskConfigController.js new file mode 100644 index 000000000000..26a0d0752cc4 --- /dev/null +++ b/js/modules/Helpdesk/HelpdeskConfigController.js @@ -0,0 +1,263 @@ +/** + * --------------------------------------------------------------------- + * + * GLPI - Gestionnaire Libre de Parc Informatique + * + * http://glpi-project.org + * + * @copyright 2015-2025 Teclib' and contributors. + * @licence https://www.gnu.org/licenses/gpl-3.0.html + * + * --------------------------------------------------------------------- + * + * LICENSE + * + * This file is part of GLPI. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * --------------------------------------------------------------------- + */ + +/* global sortable, glpi_toast_info, glpi_toast_error, getAjaxCsrfToken */ + +export class GlpiHelpdeskConfigController +{ + #container; + #is_reordering_tiles; + #profile_id; + + constructor(container, profile_id) + { + this.#container = container; + this.#is_reordering_tiles = false; + this.#profile_id = profile_id; + this.#enableSortable(); + this.#initEventsHandlers(); + } + + #enableSortable() + { + const tiles_container = this.#container + .querySelector('[data-glpi-helpdesk-config-tiles]') + ; + + sortable(tiles_container, { + // Placeholder class. + placeholder: `
+
+
+
`, + + // We don't need a class but it won't work if this param is empty. + placeholderClass: "not-a-real-class", + }); + + sortable(tiles_container)[0].addEventListener('sortstart', () => { + if (this.#is_reordering_tiles) { + return; + } + + this.#is_reordering_tiles = true; + this.#showReorderUI(); + }); + } + + #initEventsHandlers() + { + this.#container + .querySelector('[data-glpi-helpdesk-config-reorder-action-cancel') + .addEventListener('click', async () => { + await this.#reloadTiles(); + this.#hideReorderUI(); + this.#is_reordering_tiles = false; + }) + ; + + this.#container + .querySelector('[data-glpi-helpdesk-config-reorder-action-save') + .addEventListener('click', async() => { + await this.#saveTilesOrder(); + this.#hideReorderUI(); + this.#is_reordering_tiles = false; + }) + ; + + this.#container + .querySelectorAll('[data-glpi-helpdesk-config-action-delete') + .forEach((node) => { + node.addEventListener('click', (e) => { + const tile = e.target.closest('[data-glpi-helpdesk-config-tile-container]'); + this.#deleteTile(tile); + }); + }) + ; + } + + #showReorderUI() + { + this.#container + .querySelector('[data-glpi-helpdesk-config-reorder-actions]') + .classList + .remove('d-none') + ; + this.#container + .querySelectorAll('[data-glpi-helpdesk-config-extra-actions]') + .forEach((dots) => { + dots.classList.add('d-none'); + }) + ; + this.#container + .querySelectorAll('[data-glpi-helpdesk-config-tile]') + .forEach((tile_body) => { + tile_body.classList.add('border-2'); + tile_body.classList.add('border-dashed'); + }) + ; + } + + #hideReorderUI() + { + this.#container + .querySelector('[data-glpi-helpdesk-config-reorder-actions]') + .classList + .add('d-none') + ; + this.#container + .querySelectorAll('[data-glpi-helpdesk-config-extra-actions]') + .forEach((dots) => { + dots.classList.remove('d-none'); + }) + ; + this.#container + .querySelectorAll('[data-glpi-helpdesk-config-tile]') + .forEach((tile_body) => { + tile_body.classList.remove('border-2'); + tile_body.classList.remove('border-dashed'); + }) + ; + } + + async #reloadTiles() + { + try { + const url = `${CFG_GLPI.root_doc}/Config/Helpdesk/FetchTiles`; + const url_params = new URLSearchParams({ + profile_id: this.#profile_id, + }); + const response = await fetch(`${url}?${url_params}`); + if (!response.ok) { + throw new Error(response.status); + } + + this.#getTilesContainerDiv().innerHTML = await response.text(); + } catch (e) { + glpi_toast_error(__('An unexpected error occurred.')); + console.error(e); + } + } + + async #saveTilesOrder() + { + try { + // Set up form data + const form_data = new FormData(); + form_data.append('profile_id', this.#profile_id); + this.#getTilesOrder() + .forEach((id) => form_data.append("order[]", id)) + ; + + // Send request + const url = `${CFG_GLPI.root_doc}/ajax/Config/Helpdesk/SetTilesOrder`; + const response = await fetch(url, { + method: 'POST', + body: form_data, + headers: { + 'X-Glpi-Csrf-Token': getAjaxCsrfToken(), + } + }); + + // Handle server errors + if (!response.ok) { + throw new Error(response.status); + } + + // Refresh content and confirm success + this.#getTilesContainerDiv().innerHTML = await response.text(); + glpi_toast_info(__("Configuration updated successfully.")); + } catch (e) { + glpi_toast_error(__('An unexpected error occurred.')); + console.error(e); + } + } + + #getTilesOrder() + { + const nodes = this.#container + .querySelectorAll('[data-glpi-helpdesk-config-tile-profile-id]') + ; + + return [...nodes].map((node) => { + return node.dataset.glpiHelpdeskConfigTileProfileId; + }); + } + + #getTilesContainerDiv() + { + return this.#container.querySelector("[data-glpi-helpdesk-config-tiles"); + } + + async #deleteTile(tile_container) + { + // Hide content immediatly (optimistic UI) + tile_container.classList.add('d-none'); + + try { + const tile = tile_container.querySelector('[data-glpi-helpdesk-config-tile]'); + + // Set up form data + const form_data = new FormData(); + form_data.append( + 'tile_id', + tile.dataset.glpiHelpdeskConfigTileId + ); + form_data.append( + 'tile_itemtype', + tile.dataset.glpiHelpdeskConfigTileItemtype + ); + + // Send request + const url = `${CFG_GLPI.root_doc}/ajax/Config/Helpdesk/DeleteTile`; + const response = await fetch(url, { + method: 'POST', + body: form_data, + headers: { + 'X-Glpi-Csrf-Token': getAjaxCsrfToken(), + } + }); + + // Handle server errors + if (!response.ok) { + throw new Error(response.status); + } + + glpi_toast_info(__("Configuration updated successfully.")); + tile_container.remove(); + } catch (e) { + glpi_toast_error(__('An unexpected error occurred.')); + tile_container.classList.remove('d-none'); + console.error(e); + } + } +} diff --git a/phpunit/functional/Glpi/Helpdesk/DefaultDataManagerTest.php b/phpunit/functional/Glpi/Helpdesk/DefaultDataManagerTest.php index 3095936b7539..f42c1d6ef21b 100644 --- a/phpunit/functional/Glpi/Helpdesk/DefaultDataManagerTest.php +++ b/phpunit/functional/Glpi/Helpdesk/DefaultDataManagerTest.php @@ -45,12 +45,15 @@ use Glpi\Form\Form; use Glpi\Helpdesk\Tile\Profile_Tile; use Glpi\Helpdesk\Tile\TileInterface; +use Glpi\Helpdesk\Tile\TilesManager; use Glpi\Session\SessionInfo; use Glpi\Tests\FormTesterTrait; use Glpi\UI\IllustrationManager; use ITILCategory; use Location; use Monitor; +use PHPUnit\Framework\Attributes\DataProvider; +use Profile; use Session; use Ticket; use User; diff --git a/phpunit/functional/Glpi/Helpdesk/Tile/TilesManagerTest.php b/phpunit/functional/Glpi/Helpdesk/Tile/TilesManagerTest.php index ce3f122729d2..654b6d8bb777 100644 --- a/phpunit/functional/Glpi/Helpdesk/Tile/TilesManagerTest.php +++ b/phpunit/functional/Glpi/Helpdesk/Tile/TilesManagerTest.php @@ -38,6 +38,7 @@ use Glpi\Helpdesk\Tile\ExternalPageTile; use Glpi\Helpdesk\Tile\FormTile; use Glpi\Helpdesk\Tile\GlpiPageTile; +use Glpi\Helpdesk\Tile\Profile_Tile; use Glpi\Helpdesk\Tile\TilesManager; use Glpi\Session\SessionInfo; use Glpi\Tests\FormBuilder; @@ -253,4 +254,171 @@ public function testOnlyFormVisibleFromActiveEntityAreFound(): void "Form inside recursive parent entity", ], $form_names); } + + public function testTilesAreOrderedByRanks(): void + { + // Arrange: create three tiles and modify their orders + $manager = $this->getManager(); + $profile = $this->createItem(Profile::class, [ + 'name' => 'Helpdesk profile', + 'interface' => 'helpdesk', + ]); + $manager->addTile($profile, ExternalPageTile::class, [ + 'title' => "GLPI project", + 'description' => "Link to GLPI project website", + 'illustration' => "request-service", + 'url' => "https://glpi-project.org", + ]); + $profile_tile_id = $manager->addTile($profile, GlpiPageTile::class, [ + 'title' => "FAQ", + 'description' => "Link to the FAQ", + 'illustration' => "browse-kb", + 'page' => GlpiPageTile::PAGE_FAQ, + ]); + $manager->addTile($profile, ExternalPageTile::class, [ + 'title' => "Support", + 'description' => "Link to teclib support", + 'illustration' => "report-issue", + 'url' => "https://support.teclib.org", + ]); + + // Get the second tile and move it at the end + $this->updateItem(Profile_Tile::class, $profile_tile_id, [ + 'rank' => 10, + ]); + + // Act: get tiles + $session = new SessionInfo(profile_id: $profile->getID()); + $tiles = $manager->getTiles($session); + + // Assert: tiles must be in the expected order + $this->assertCount(3, $tiles); + + $first_tile = $tiles[0]; + $this->assertInstanceOf(ExternalPageTile::class, $first_tile); + $this->assertEquals("GLPI project", $first_tile->getTitle()); + $this->assertEquals("Link to GLPI project website", $first_tile->getDescription()); + $this->assertEquals("request-service", $first_tile->getIllustration()); + $this->assertEquals("https://glpi-project.org", $first_tile->getTileUrl()); + + $second_tile = $tiles[1]; + $this->assertInstanceOf(ExternalPageTile::class, $second_tile); + $this->assertEquals("Support", $second_tile->getTitle()); + $this->assertEquals("Link to teclib support", $second_tile->getDescription()); + $this->assertEquals("report-issue", $second_tile->getIllustration()); + $this->assertEquals("https://support.teclib.org", $second_tile->getTileUrl()); + + $third_tile = $tiles[2]; + $this->assertInstanceOf(GlpiPageTile::class, $third_tile); + $this->assertEquals("FAQ", $third_tile->getTitle()); + $this->assertEquals("Link to the FAQ", $third_tile->getDescription()); + $this->assertEquals("browse-kb", $third_tile->getIllustration()); + $this->assertEquals("/glpi/front/helpdesk.faq.php", $third_tile->getTileUrl()); + } + + public function testTilesOrderCanBeSet(): void + { + // Arrange: create three tiles + $manager = $this->getManager(); + $profile = $this->createItem(Profile::class, [ + 'name' => 'Helpdesk profile', + 'interface' => 'helpdesk', + ]); + $profile_tile_id_1 = $manager->addTile($profile, ExternalPageTile::class, [ + 'title' => "GLPI project", + 'description' => "Link to GLPI project website", + 'illustration' => "request-service", + 'url' => "https://glpi-project.org", + ]); + $profile_tile_id_2 = $manager->addTile($profile, GlpiPageTile::class, [ + 'title' => "FAQ", + 'description' => "Link to the FAQ", + 'illustration' => "browse-kb", + 'page' => GlpiPageTile::PAGE_FAQ, + ]); + $profile_tile_id_3 = $manager->addTile($profile, ExternalPageTile::class, [ + 'title' => "Support", + 'description' => "Link to teclib support", + 'illustration' => "report-issue", + 'url' => "https://support.teclib.org", + ]); + + // Act: set a new order + $manager->setOrderForProfile($profile, [ + $profile_tile_id_3, + $profile_tile_id_1, + $profile_tile_id_2, + ]); + + // Assert: confirm the new order + $session = new SessionInfo(profile_id: $profile->getID()); + $tiles = $manager->getTiles($session); + + $first_tile = $tiles[0]; + $this->assertInstanceOf(ExternalPageTile::class, $first_tile); + $this->assertEquals("Support", $first_tile->getTitle()); + $this->assertEquals("Link to teclib support", $first_tile->getDescription()); + $this->assertEquals("report-issue", $first_tile->getIllustration()); + $this->assertEquals("https://support.teclib.org", $first_tile->getTileUrl()); + + $second_tile = $tiles[1]; + $this->assertInstanceOf(ExternalPageTile::class, $second_tile); + $this->assertEquals("GLPI project", $second_tile->getTitle()); + $this->assertEquals("Link to GLPI project website", $second_tile->getDescription()); + $this->assertEquals("request-service", $second_tile->getIllustration()); + $this->assertEquals("https://glpi-project.org", $second_tile->getTileUrl()); + + $third_tile = $tiles[2]; + $this->assertInstanceOf(GlpiPageTile::class, $third_tile); + $this->assertEquals("FAQ", $third_tile->getTitle()); + $this->assertEquals("Link to the FAQ", $third_tile->getDescription()); + $this->assertEquals("browse-kb", $third_tile->getIllustration()); + $this->assertEquals("/glpi/front/helpdesk.faq.php", $third_tile->getTileUrl()); + } + + public function testDeleteTile(): void + { + // Arrange: create a profile with some tiles + $manager = $this->getManager(); + $profile = $this->createItem(Profile::class, [ + 'name' => 'Helpdesk profile', + 'interface' => 'helpdesk', + ]); + $manager->addTile($profile, ExternalPageTile::class, [ + 'title' => "GLPI project", + 'description' => "Link to GLPI project website", + 'illustration' => "request-service", + 'url' => "https://glpi-project.org", + ]); + $profile_tile_id_2 = $manager->addTile($profile, GlpiPageTile::class, [ + 'title' => "FAQ", + 'description' => "Link to the FAQ", + 'illustration' => "browse-kb", + 'page' => GlpiPageTile::PAGE_FAQ, + ]); + $manager->addTile($profile, ExternalPageTile::class, [ + 'title' => "Support", + 'description' => "Link to teclib support", + 'illustration' => "report-issue", + 'url' => "https://support.teclib.org", + ]); + + // Act: delete the second tile + $profile_tile = Profile_Tile::getById($profile_tile_id_2); + $tile_id = $profile_tile->fields['items_id']; + $this->getManager()->deleteTile(GlpiPageTile::getById($tile_id)); + + // Assert: the tile must not be found and must be cleared from the DB + $session = new SessionInfo(profile_id: $profile->getID()); + $tiles = $manager->getTiles($session); + $this->assertCount(2, $tiles); + + $first_tile = $tiles[0]; + $second_tile = $tiles[1]; + $this->assertNotEquals("FAQ", $first_tile->getTitle()); + $this->assertNotEquals("FAQ", $second_tile->getTitle()); + + $this->assertFalse(Profile_Tile::getById($profile_tile_id_2)); + $this->assertFalse(GlpiPageTile::getById($tile_id)); + } } diff --git a/src/Glpi/Controller/Config/Helpdesk/DeleteTileController.php b/src/Glpi/Controller/Config/Helpdesk/DeleteTileController.php new file mode 100644 index 000000000000..30f8e8adf67c --- /dev/null +++ b/src/Glpi/Controller/Config/Helpdesk/DeleteTileController.php @@ -0,0 +1,99 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Controller\Config\Helpdesk; + +use CommonDBTM; +use Config; +use Glpi\Controller\AbstractController; +use Glpi\Exception\Http\AccessDeniedHttpException; +use Glpi\Exception\Http\BadRequestHttpException; +use Glpi\Exception\Http\NotFoundHttpException; +use Glpi\Helpdesk\HomePageTabs; +use Glpi\Helpdesk\Tile\TileInterface; +use Glpi\Helpdesk\Tile\TilesManager; +use Glpi\Http\Firewall; +use Glpi\Security\Attribute\SecurityStrategy; +use Glpi\Session\SessionInfo; +use Profile; +use Session; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use User; + +final class DeleteTileController extends AbstractController +{ + private TilesManager $tiles_manager; + + public function __construct() + { + $this->tiles_manager = new TilesManager(); + } + + #[Route( + "/ajax/Config/Helpdesk/DeleteTile", + name: "glpi_config_helpdesk_delete_tile", + methods: "POST" + )] + public function __invoke(Request $request): Response + { + if (!Session::haveRight(Config::$rightname, UPDATE)) { + throw new AccessDeniedHttpException(); + } + + // Read parameters + $tile_id = $request->request->getInt('tile_id'); + $tile_itemtype = $request->request->getString('tile_itemtype'); + + // Validate parameters + if ( + $tile_id == 0 + || !is_a($tile_itemtype, TileInterface::class, true) + || !is_a($tile_itemtype, CommonDBTM::class, true) + ) { + throw new BadRequestHttpException(); + } + + // Try to load the given tile + $tile = $tile_itemtype::getById($tile_id); + if (!$tile) { + throw new NotFoundHttpException(); + } + + // Delete tyle and return an empty response + $this->tiles_manager->deleteTile($tile); + return new Response(); + } +} diff --git a/src/Glpi/Controller/Config/Helpdesk/FetchTilesController.php b/src/Glpi/Controller/Config/Helpdesk/FetchTilesController.php new file mode 100644 index 000000000000..0c12b39441f0 --- /dev/null +++ b/src/Glpi/Controller/Config/Helpdesk/FetchTilesController.php @@ -0,0 +1,81 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Controller\Config\Helpdesk; + +use Config; +use Glpi\Controller\AbstractController; +use Glpi\Exception\Http\AccessDeniedHttpException; +use Glpi\Helpdesk\HomePageTabs; +use Glpi\Helpdesk\Tile\TilesManager; +use Glpi\Http\Firewall; +use Glpi\Security\Attribute\SecurityStrategy; +use Glpi\Session\SessionInfo; +use Session; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use User; + +final class FetchTilesController extends AbstractController +{ + private TilesManager $tiles_manager; + + public function __construct() + { + $this->tiles_manager = new TilesManager(); + } + + #[Route( + "/Config/Helpdesk/FetchTiles", + name: "glpi_config_helpdesk_fetch_tiles", + methods: "GET" + )] + public function __invoke(Request $request): Response + { + if (!Session::haveRight(Config::$rightname, READ)) { + throw new AccessDeniedHttpException(); + } + + $profile_id = $request->query->getInt('profile_id'); + $tiles = $this->tiles_manager->getTiles(new SessionInfo( + profile_id: $profile_id, + ), bypass_rights: true); + + return $this->render('pages/admin/helpdesk_home_config_tiles.html.twig', [ + 'tiles_manager' => $this->tiles_manager, + 'tiles' => $tiles, + ]); + } +} diff --git a/src/Glpi/Controller/Config/Helpdesk/SetTilesOrderController.php b/src/Glpi/Controller/Config/Helpdesk/SetTilesOrderController.php new file mode 100644 index 000000000000..3cdd2913ef12 --- /dev/null +++ b/src/Glpi/Controller/Config/Helpdesk/SetTilesOrderController.php @@ -0,0 +1,89 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Controller\Config\Helpdesk; + +use Config; +use Glpi\Controller\AbstractController; +use Glpi\Exception\Http\AccessDeniedHttpException; +use Glpi\Helpdesk\HomePageTabs; +use Glpi\Helpdesk\Tile\TilesManager; +use Glpi\Http\Firewall; +use Glpi\Security\Attribute\SecurityStrategy; +use Glpi\Session\SessionInfo; +use Profile; +use Session; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; +use User; + +final class SetTilesOrderController extends AbstractController +{ + private TilesManager $tiles_manager; + + public function __construct() + { + $this->tiles_manager = new TilesManager(); + } + + #[Route( + "/ajax/Config/Helpdesk/SetTilesOrder", + name: "glpi_config_helpdesk_set_tiles_order", + methods: "POST" + )] + public function __invoke(Request $request): Response + { + if (!Session::haveRight(Config::$rightname, UPDATE)) { + throw new AccessDeniedHttpException(); + } + + // Apply new order + $profile_id = $request->request->getInt('profile_id'); + $order = $request->request->all()['order']; + $this->tiles_manager->setOrderForProfile( + Profile::getById($profile_id), + $order + ); + + // Reload tiles + $tiles = $this->tiles_manager->getTiles(new SessionInfo( + profile_id: $profile_id, + ), bypass_rights: true); + return $this->render('pages/admin/helpdesk_home_config_tiles.html.twig', [ + 'tiles_manager' => $this->tiles_manager, + 'tiles' => $tiles, + ]); + } +} diff --git a/src/Glpi/Helpdesk/Tile/ExternalPageTile.php b/src/Glpi/Helpdesk/Tile/ExternalPageTile.php index 4aa219e96331..97457ae87781 100644 --- a/src/Glpi/Helpdesk/Tile/ExternalPageTile.php +++ b/src/Glpi/Helpdesk/Tile/ExternalPageTile.php @@ -40,6 +40,14 @@ final class ExternalPageTile extends CommonDBTM implements TileInterface { + public static $rightname = 'config'; + + #[Override] + public static function canCreate(): bool + { + return self::canUpdate(); + } + #[Override] public function getTitle(): string { @@ -69,4 +77,10 @@ public function isValid(SessionInfo $session_info): bool { return true; } + + #[Override] + public function getDatabaseId(): int + { + return $this->fields['id']; + } } diff --git a/src/Glpi/Helpdesk/Tile/FormTile.php b/src/Glpi/Helpdesk/Tile/FormTile.php index 43dd9190961c..ede7fd71a86a 100644 --- a/src/Glpi/Helpdesk/Tile/FormTile.php +++ b/src/Glpi/Helpdesk/Tile/FormTile.php @@ -44,11 +44,18 @@ final class FormTile extends CommonDBChild implements TileInterface { + public static $rightname = 'config'; public static $itemtype = Form::class; public static $items_id = 'forms_forms_id'; private ?Form $form; + #[Override] + public static function canCreate(): bool + { + return self::canUpdate(); + } + #[Override] public function post_getFromDB(): void { @@ -125,4 +132,10 @@ public function isValid(SessionInfo $session_info): bool return true; } + + #[Override] + public function getDatabaseId(): int + { + return $this->fields['id']; + } } diff --git a/src/Glpi/Helpdesk/Tile/GlpiPageTile.php b/src/Glpi/Helpdesk/Tile/GlpiPageTile.php index bbb11c9a8300..7c841edd33e3 100644 --- a/src/Glpi/Helpdesk/Tile/GlpiPageTile.php +++ b/src/Glpi/Helpdesk/Tile/GlpiPageTile.php @@ -42,11 +42,19 @@ final class GlpiPageTile extends CommonDBTM implements TileInterface { + public static $rightname = 'config'; + public const PAGE_SERVICE_CATALOG = 'service_catalog'; public const PAGE_FAQ = 'faq'; public const PAGE_RESERVATION = 'reservation'; public const PAGE_APPROVAL = 'approval'; + #[Override] + public static function canCreate(): bool + { + return self::canUpdate(); + } + #[Override] public function getTitle(): string { @@ -94,4 +102,10 @@ public function isValid(SessionInfo $session_info): bool default => false, }; } + + #[Override] + public function getDatabaseId(): int + { + return $this->fields['id']; + } } diff --git a/src/Glpi/Helpdesk/Tile/TileInterface.php b/src/Glpi/Helpdesk/Tile/TileInterface.php index 8862cf7709a2..d9fb86315d4b 100644 --- a/src/Glpi/Helpdesk/Tile/TileInterface.php +++ b/src/Glpi/Helpdesk/Tile/TileInterface.php @@ -45,5 +45,8 @@ public function getDescription(): string; public function getIllustration(): string; public function getTileUrl(): string; + public function isValid(SessionInfo $session_info): bool; + + public function getDatabaseId(): int; } diff --git a/src/Glpi/Helpdesk/Tile/TilesManager.php b/src/Glpi/Helpdesk/Tile/TilesManager.php index ec8eccb74c15..66a6ab2dc095 100644 --- a/src/Glpi/Helpdesk/Tile/TilesManager.php +++ b/src/Glpi/Helpdesk/Tile/TilesManager.php @@ -34,6 +34,7 @@ namespace Glpi\Helpdesk\Tile; +use CommonDBTM; use Glpi\Session\SessionInfo; use InvalidArgumentException; use RuntimeException; @@ -42,12 +43,14 @@ final class TilesManager { /** @return TileInterface[] */ - public function getTiles(SessionInfo $session_info): array - { + public function getTiles( + SessionInfo $session_info, + bool $bypass_rights = false + ): array { // Load tiles for the given profile $profile_tiles = (new Profile_Tile())->find([ 'profiles_id' => $session_info->getProfileId(), - ]); + ], ['rank']); $tiles = []; foreach ($profile_tiles as $row) { @@ -65,7 +68,7 @@ public function getTiles(SessionInfo $session_info): array } // Make sure the tile is valid for the given session and entity details - if (!$tile->isValid($session_info)) { + if (!$bypass_rights && !$tile->isValid($session_info)) { continue; } @@ -80,7 +83,7 @@ public function addTile( Profile $profile, string $tile_class, array $params - ): void { + ): int { if ($profile->fields['interface'] !== 'helpdesk') { throw new InvalidArgumentException("Only helpdesk profiles can have tiles"); } @@ -96,9 +99,97 @@ public function addTile( 'profiles_id' => $profile->getID(), 'items_id' => $id, 'itemtype' => $tile_class, + 'rank' => countElementsInTable(Profile_Tile::getTable(), [ + 'profiles_id' => $profile->getID(), + ]), ]); if (!$id) { throw new RuntimeException("Failed to link tile to profile"); } + + return $id; + } + + public function getProfileTileForTile(TileInterface $tile): Profile_Tile + { + $profile_tile = new Profile_Tile(); + $get_by_crit_success = $profile_tile->getFromDBByCrit([ + 'itemtype' => $tile::class, + 'items_id' => $tile->getDatabaseId(), + ]); + + if (!$get_by_crit_success) { + throw new RuntimeException("Missing Profile_Tile data"); + } + + return $profile_tile; + } + + /** + * @param int[] $order Ids of the Profile_Tile entries, sorted into the desired ranks + */ + public function setOrderForProfile(Profile $profile, array $order): void + { + // Increase the original ranks to avoid unicity conflicts when setting + // the new ranks. + $max_rank = $this->getMaxUsedRankForProfile($profile); + $profile_tiles = (new Profile_Tile())->find([ + 'profiles_id' => $profile->getID() + ]); + $profile_tiles_ids = array_column($profile_tiles, 'id'); + foreach ($profile_tiles_ids as $i => $id) { + $profile_tile = new Profile_Tile(); + $profile_tile->update([ + 'id' => $id, + 'rank' => $i + ++$max_rank, + ]); + } + + // Set new ranks + foreach (array_values($order) as $rank => $id) { + // Find the associated Profile_Tile + $profile_tile = new Profile_Tile(); + $profile_tile->update([ + 'id' => $id, + 'rank' => $rank, + ]); + } + } + + public function deleteTile(CommonDBTM&TileInterface $tile): void + { + // First, find and delete the relevant Profile_Tile row + $profile_tiles = (new Profile_Tile())->find([ + 'items_id' => $tile->getDatabaseId(), + 'itemtype' => $tile::class, + ]); + foreach ($profile_tiles as $profile_tile_row) { + $id = $profile_tile_row['id']; + $delete = (new Profile_Tile())->delete(['id' => $id]); + if (!$delete) { + throw new RuntimeException("Failed to delete profile tile ($id)"); + } + } + + // Then delete the tile itself + $id = $tile->getDatabaseId(); + $delete = $tile->delete(['id' => $id]); + if (!$delete) { + throw new RuntimeException("Failed to delete tile ($id)"); + } + } + + private function getMaxUsedRankForProfile(Profile $profile): int + { + /** @var \DBmysql $DB */ + global $DB; + + $rank = $DB->request([ + 'SELECT' => ['MAX' => "rank AS max_rank"], + 'FROM' => Profile_Tile::getTable(), + 'WHERE' => ['profiles_id' => $profile->getID()], + ])->current(); + + return $rank['max_rank']; } } diff --git a/src/Profile.php b/src/Profile.php index 16d48e3ba293..dcf7853fa98c 100644 --- a/src/Profile.php +++ b/src/Profile.php @@ -39,6 +39,9 @@ use Glpi\DBAL\QuerySubQuery; use Glpi\Event; use Glpi\Form\Form; +use Glpi\Helpdesk\DefaultDataManager; +use Glpi\Helpdesk\Tile\TilesManager; +use Glpi\Session\SessionInfo; use Glpi\Toolbox\ArrayNormalizer; /** @@ -148,6 +151,7 @@ public function defineTabs($options = []) $this->addStandardTab(__CLASS__, $ong, $options); $this->addStandardTab('Profile_User', $ong, $options); $this->addStandardTab('Log', $ong, $options); + return $ong; } @@ -158,10 +162,11 @@ public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0) case self::class: if ($item->fields['interface'] === 'helpdesk') { $ong[3] = self::createTabEntry(__('Assistance'), 0, $item::class, 'ti ti-headset'); // Helpdesk - $ong[4] = self::createTabEntry(__('Life cycles')); + $ong[4] = self::createTabEntry(__('Helpdesk home'), 0, $item::class, 'ti ti-home'); + $ong[5] = self::createTabEntry(__('Life cycles')); $ong[6] = self::createTabEntry(__('Tools'), 0, $item::class, 'ti ti-briefcase'); - $ong[8] = self::createTabEntry(__('Setup'), 0, $item::class, 'ti ti-cog'); - $ong[9] = self::createTabEntry(__('Security'), 0, $item::class, 'ti ti-shield-lock'); + $ong[7] = self::createTabEntry(__('Setup'), 0, $item::class, 'ti ti-cog'); + $ong[8] = self::createTabEntry(__('Security'), 0, $item::class, 'ti ti-shield-lock'); } else { $ong[2] = self::createTabEntry(_n('Asset', 'Assets', Session::getPluralNumber()), 0, $item::class, 'ti ti-package'); $ong[3] = self::createTabEntry(__('Assistance'), 0, $item::class, 'ti ti-headset'); @@ -182,56 +187,34 @@ public static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $ { if ($item::class === self::class) { $item->cleanProfile(); - switch ($tabnum) { - case 2: - $item->showFormAsset(); - break; - - case 3: - if ($item->fields['interface'] === 'helpdesk') { - $item->showFormTrackingHelpdesk(); - } else { - $item->showFormTracking(); - } - break; - - case 4: - if ($item->fields['interface'] === 'helpdesk') { - $item->showFormLifeCycleHelpdesk(); - } else { - $item->showFormLifeCycle(); - } - break; - - case 5: - $item->showFormManagement(); - break; - - case 6: - if ($item->fields['interface'] === 'helpdesk') { - $item->showFormToolsHelpdesk(); - } else { - $item->showFormTools(); - } - break; - - case 7: - $item->showFormAdmin(); - break; - - case 8: - if ($item->fields['interface'] === 'helpdesk') { - $item->showFormSetupHelpdesk(); - } else { - $item->showFormSetup(); - } - break; - - case 9: - $item->showFormSecurity(); - break; + if ($item->fields['interface'] === 'helpdesk') { + $ret = match ((int) $tabnum) { + 2 => $item->showFormAsset(), + 3 => $item->showFormTrackingHelpdesk(), + 4 => $item->showHelpdeskHomeConfig(), + 5 => $item->showFormLifeCycleHelpdesk(), + 6 => $item->showFormToolsHelpdesk(), + 7 => $item->showFormSetupHelpdesk(), + 8 => $item->showFormSecurity(), + default => false, + }; + } else { + $ret = match ((int) $tabnum) { + 2 => $item->showFormAsset(), + 3 => $item->showFormTracking(), + 4 => $item->showFormLifeCycle(), + 5 => $item->showFormManagement(), + 6 => $item->showFormTools(), + 7 => $item->showFormAdmin(), + 8 => $item->showFormSetup(), + 9 => $item->showFormSecurity(), + default => false, + }; } + + return $ret; } + return true; } @@ -4399,4 +4382,23 @@ public function canPurgeItem(): bool return true; } + + public function showHelpdeskHomeConfig(): bool + { + // Load tiles of the current profile + $tiles_manager = new TilesManager(); + $tiles = $tiles_manager->getTiles(new SessionInfo( + profile_id: $this->getID(), + ), bypass_rights: true); + + // Render content + $twig = TemplateRenderer::getInstance(); + $twig->display('pages/admin/helpdesk_home_config.html.twig', [ + 'tiles_manager' => $tiles_manager, + 'tiles' => $tiles, + 'profile_id' => $this->getID(), + ]); + + return true; + } } diff --git a/templates/pages/admin/helpdesk_home_config.html.twig b/templates/pages/admin/helpdesk_home_config.html.twig new file mode 100644 index 000000000000..b0e7e776b814 --- /dev/null +++ b/templates/pages/admin/helpdesk_home_config.html.twig @@ -0,0 +1,82 @@ +{# + # --------------------------------------------------------------------- + # + # GLPI - Gestionnaire Libre de Parc Informatique + # + # http://glpi-project.org + # + # @copyright 2015-2025 Teclib' and contributors. + # @licence https://www.gnu.org/licenses/gpl-3.0.html + # + # --------------------------------------------------------------------- + # + # LICENSE + # + # This file is part of GLPI. + # + # This program is free software: you can redistribute it and/or modify + # it under the terms of the GNU General Public License as published by + # the Free Software Foundation, either version 3 of the License, or + # (at your option) any later version. + # + # This program is distributed in the hope that it will be useful, + # but WITHOUT ANY WARRANTY; without even the implied warranty of + # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + # GNU General Public License for more details. + # + # You should have received a copy of the GNU General Public License + # along with this program. If not, see . + # + # --------------------------------------------------------------------- + #} + +{% set container_id = "glpi-helpdesk-config-container-" ~ random() %} + +
+
+

+ {{ __("Home tiles configuration") }} +

+ +
+ {# The tiles have their own templates as they will be reloaded using AJAX #} + {{ include('pages/admin/helpdesk_home_config_tiles.html.twig', { + 'tiles_manager': tiles_manager, + 'tiles': tiles, + }, with_context = false) }} +
+ +
+ + +
+
+
+ +{# Start js controller #} + diff --git a/templates/pages/admin/helpdesk_home_config_tiles.html.twig b/templates/pages/admin/helpdesk_home_config_tiles.html.twig new file mode 100644 index 000000000000..8dca326b7b8e --- /dev/null +++ b/templates/pages/admin/helpdesk_home_config_tiles.html.twig @@ -0,0 +1,94 @@ +{# + # --------------------------------------------------------------------- + # + # GLPI - Gestionnaire Libre de Parc Informatique + # + # http://glpi-project.org + # + # @copyright 2015-2025 Teclib' and contributors. + # @licence https://www.gnu.org/licenses/gpl-3.0.html + # + # --------------------------------------------------------------------- + # + # LICENSE + # + # This file is part of GLPI. + # + # This program is free software: you can redistribute it and/or modify + # it under the terms of the GNU General Public License as published by + # the Free Software Foundation, either version 3 of the License, or + # (at your option) any later version. + # + # This program is distributed in the hope that it will be useful, + # but WITHOUT ANY WARRANTY; without even the implied warranty of + # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + # GNU General Public License for more details. + # + # You should have received a copy of the GNU General Public License + # along with this program. If not, see . + # + # --------------------------------------------------------------------- + #} + +{% for tile in tiles %} + {% set profile_tile_id = tiles_manager.getProfileTileForTile(tile).getID() %} + +
+
+
+
+
+ {{ render_illustration(tile.getIllustration(), 70) }} +
+
+
+

+ {{ tile.getTitle() }} +

+ + +
+
+ {{ tile.getDescription() }} +
+
+
+
+
+
+{% endfor %} diff --git a/tests/cypress/e2e/self-service/home_config.cy.js b/tests/cypress/e2e/self-service/home_config.cy.js new file mode 100644 index 000000000000..febcdfd764a9 --- /dev/null +++ b/tests/cypress/e2e/self-service/home_config.cy.js @@ -0,0 +1,208 @@ +/** + * --------------------------------------------------------------------- + * + * GLPI - Gestionnaire Libre de Parc Informatique + * + * http://glpi-project.org + * + * @copyright 2015-2025 Teclib' and contributors. + * @licence https://www.gnu.org/licenses/gpl-3.0.html + * + * --------------------------------------------------------------------- + * + * LICENSE + * + * This file is part of GLPI. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * --------------------------------------------------------------------- + */ + +describe('Helpdesk home page configuration', () => { + beforeEach(() => { + cy.login(); + + // Set up a new profile with 4 tiles + cy.createWithAPI('Profile', { + 'name': 'Helpdesk profile for e2e tests', + 'interface': 'helpdesk', + }).then((profile_id) => { + const tiles = [ + { + title: "Browse help articles", + description: "See all available help articles and our FAQ.", + illustration: "browse-kb", + page: "faq", + }, + { + title: "Request a service", + description: "Ask for a service to be provided by our team.", + illustration: "request-service", + page: "service_catalog ", + }, + { + title: "Make a reservation", + description: "Pick an available asset and reserve it for a given date.", + illustration: "reservation", + page: "reservation", + }, + { + title: "View approval requests", + description: "View all tickets waiting for your validation.", + illustration: "approve-requests", + page: "approval", + }, + ]; + tiles.forEach((tile, i) => { + cy.createWithAPI( + 'Glpi\\Helpdesk\\Tile\\GlpiPageTile', + tile + ).then((tile_id) => { + cy.createWithAPI('Glpi\\Helpdesk\\Tile\\Profile_Tile', { + 'profiles_id': profile_id, + 'itemtype': 'Glpi\\Helpdesk\\Tile\\GlpiPageTile', + 'items_id': tile_id, + 'rank': i, + }); + }); + }); + + cy.visit(`/front/profile.form.php?id=${profile_id}&forcetab=Profile$4`); + }); + + // Need the JS controller to be ready + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + + // html5sortable add the wrong "option" role, we must remove it + cy.window().then((win) => { + win.document + .querySelector('[data-glpi-helpdesk-config-tiles]') + .querySelectorAll('section') + .forEach((node) => node.removeAttribute('role')) + ; + }); + }); + + function validateTilesOrder(tiles) { + cy.findByRole("region", {'name': "Home tiles configuration"}).within(() => { + tiles.forEach((title, i) => { + cy.findAllByRole("region").eq(i).should('have.attr', 'aria-label', title); + }); + }); + } + + function validateOrderControlsAreHidden() { + cy.findByRole('button', {'name': "Cancel"}).should('not.exist'); + cy.findByRole('button', {'name': "Save order"}).should('not.exist'); + } + + function validateOrderControlsAreShown() { + cy.findByRole('button', {'name': "Cancel"}).should('be.visible'); + cy.findByRole('button', {'name': "Save order"}).should('be.visible'); + } + + function moveTileAfterTile(subject, destination) { + // Because the drag and drop was faked, we need to manually trigger the + // sortstart event to display the actions + cy.get("[data-glpi-helpdesk-config-tiles]").trigger('sortstart'); + + cy.findByRole("region", {'name': subject}).startToDrag(); + cy.findByRole("region", {'name': destination}).dropDraggedItemAfter(); + } + + it('can reorder tiles', () => { + // Valide default order + validateTilesOrder([ + "Browse help articles", + "Request a service", + "Make a reservation", + "View approval requests", + ]); + validateOrderControlsAreHidden(); + + // Change order + moveTileAfterTile("Browse help articles", "Make a reservation"); + validateTilesOrder([ + "Request a service", + "Make a reservation", + "Browse help articles", + "View approval requests", + ]); + validateOrderControlsAreShown(); + + // Revert to original order + cy.findByRole('button', {'name': "Cancel"}).click(); + validateTilesOrder([ + "Browse help articles", + "Request a service", + "Make a reservation", + "View approval requests", + ]); + validateOrderControlsAreHidden(); + + // Change order again + moveTileAfterTile("View approval requests", "Request a service"); + validateTilesOrder([ + "Browse help articles", + "Request a service", + "View approval requests", + "Make a reservation", + ]); + validateOrderControlsAreShown(); + + // Save new order + cy.findByRole('button', {'name': "Save order"}).click(); + cy.findByRole('alert').should( + 'contain.text', + "Configuration updated successfully." + ); + validateTilesOrder([ + "Browse help articles", + "Request a service", + "View approval requests", + "Make a reservation", + ]); + validateOrderControlsAreHidden(); + }); + + it('can remove tiles', () => { + // Delete tile + cy.findByRole("region", {'name': "Request a service"}).within(() => { + cy.findByRole('button', {'name': 'Show more actions'}).click(); + }); + cy.findByRole('button', {'name': 'Delete tile'}).click(); + + // Validate deletion + cy.findByRole("region", {'name': "Request a service"}).should('not.exist'); + cy.findByRole('alert').should( + 'contain.text', + "Configuration updated successfully." + ); + validateTilesOrder([ + "Browse help articles", + "Make a reservation", + "View approval requests", + ]); + + // Refresh page to confirm deletion + cy.reload(); + validateTilesOrder([ + "Browse help articles", + "Make a reservation", + "View approval requests", + ]); + }); +}); diff --git a/tests/cypress/support/commands.d.ts b/tests/cypress/support/commands.d.ts index 981a8908fdbf..f9c8010b1636 100644 --- a/tests/cypress/support/commands.d.ts +++ b/tests/cypress/support/commands.d.ts @@ -5,7 +5,7 @@ * * http://glpi-project.org * - * @copyright 2015-2024 Teclib' and contributors. + * @copyright 2015-2025 Teclib' and contributors. * @copyright 2003-2014 by the INDEPNET Development Team. * @licence https://www.gnu.org/licenses/gpl-3.0.html * diff --git a/tests/cypress/support/commands/form.d.ts b/tests/cypress/support/commands/form.d.ts index 50c1a2afe58a..33fc4e152589 100644 --- a/tests/cypress/support/commands/form.d.ts +++ b/tests/cypress/support/commands/form.d.ts @@ -5,7 +5,7 @@ * * http://glpi-project.org * - * @copyright 2015-2024 Teclib' and contributors. + * @copyright 2015-2025 Teclib' and contributors. * @copyright 2003-2014 by the INDEPNET Development Team. * @licence https://www.gnu.org/licenses/gpl-3.0.html * diff --git a/tests/cypress/support/commands/select2.d.ts b/tests/cypress/support/commands/select2.d.ts index 230e8203de48..2510a380ef2d 100644 --- a/tests/cypress/support/commands/select2.d.ts +++ b/tests/cypress/support/commands/select2.d.ts @@ -5,7 +5,7 @@ * * http://glpi-project.org * - * @copyright 2015-2024 Teclib' and contributors. + * @copyright 2015-2025 Teclib' and contributors. * @copyright 2003-2014 by the INDEPNET Development Team. * @licence https://www.gnu.org/licenses/gpl-3.0.html *