diff --git a/css/includes/_base.scss b/css/includes/_base.scss
index a17161e4e11..741d48d72ab 100644
--- a/css/includes/_base.scss
+++ b/css/includes/_base.scss
@@ -480,3 +480,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 c67a43e0a51..f019ba421d8 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 e52e4320afd..5031fb48fb3 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
@@ -229,9 +229,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;"
);
}
diff --git a/install/mysql/glpi-empty.sql b/install/mysql/glpi-empty.sql
index 2c545e9fec9..80309fc874d 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 00000000000..77d0f17e82a
--- /dev/null
+++ b/js/modules/Helpdesk/HelpdeskConfigController.js
@@ -0,0 +1,265 @@
+/**
+ * ---------------------------------------------------------------------
+ *
+ * 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-Requested-With': 'XMLHttpRequest',
+ '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-Requested-With': 'XMLHttpRequest',
+ '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/Tile/TilesManagerTest.php b/phpunit/functional/Glpi/Helpdesk/Tile/TilesManagerTest.php
index ce3f122729d..654b6d8bb77 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 00000000000..6d25ca75712
--- /dev/null
+++ b/src/Glpi/Controller/Config/Helpdesk/DeleteTileController.php
@@ -0,0 +1,93 @@
+.
+ *
+ * ---------------------------------------------------------------------
+ */
+
+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\Tile\TileInterface;
+use Glpi\Helpdesk\Tile\TilesManager;
+use Session;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+
+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 00000000000..74184b128ff
--- /dev/null
+++ b/src/Glpi/Controller/Config/Helpdesk/FetchTilesController.php
@@ -0,0 +1,77 @@
+.
+ *
+ * ---------------------------------------------------------------------
+ */
+
+namespace Glpi\Controller\Config\Helpdesk;
+
+use Config;
+use Glpi\Controller\AbstractController;
+use Glpi\Exception\Http\AccessDeniedHttpException;
+use Glpi\Helpdesk\Tile\TilesManager;
+use Glpi\Session\SessionInfo;
+use Session;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+
+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,
+ ), check_availability: false);
+
+ 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 00000000000..b2eecc5d70e
--- /dev/null
+++ b/src/Glpi/Controller/Config/Helpdesk/SetTilesOrderController.php
@@ -0,0 +1,85 @@
+.
+ *
+ * ---------------------------------------------------------------------
+ */
+
+namespace Glpi\Controller\Config\Helpdesk;
+
+use Config;
+use Glpi\Controller\AbstractController;
+use Glpi\Exception\Http\AccessDeniedHttpException;
+use Glpi\Helpdesk\Tile\TilesManager;
+use Glpi\Session\SessionInfo;
+use Profile;
+use Session;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Attribute\Route;
+
+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,
+ ), check_availability: false);
+ 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 4aa219e9633..86e345d596d 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
{
@@ -65,8 +73,14 @@ public function getTileUrl(): string
}
#[Override]
- public function isValid(SessionInfo $session_info): bool
+ public function isAvailable(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 43dd9190961..0195cd82036 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
{
@@ -103,7 +110,7 @@ public function getTileUrl(): string
}
#[Override]
- public function isValid(SessionInfo $session_info): bool
+ public function isAvailable(SessionInfo $session_info): bool
{
$form_access_manager = FormAccessControlManager::getInstance();
@@ -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 bbb11c9a830..a182f08683b 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
{
@@ -81,7 +89,7 @@ public function getTileUrl(): string
}
#[Override]
- public function isValid(SessionInfo $session_info): bool
+ public function isAvailable(SessionInfo $session_info): bool
{
return match ($this->fields['page']) {
self::PAGE_SERVICE_CATALOG => true,
@@ -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 8862cf7709a..0fea838531e 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 isAvailable(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 ec8eccb74c1..36340f910a3 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 $check_availability = true
+ ): 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 ($check_availability && !$tile->isAvailable($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 16d48e3ba29..677ecf5a583 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;
}
+
+ private function showHelpdeskHomeConfig(): bool
+ {
+ // Load tiles of the current profile
+ $tiles_manager = new TilesManager();
+ $tiles = $tiles_manager->getTiles(new SessionInfo(
+ profile_id: $this->getID(),
+ ), check_availability: false);
+
+ // 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 00000000000..b0e7e776b81
--- /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 00000000000..8dca326b7b8
--- /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() %}
+
+
+