From 30e61a4b5d8f8266ff5c55700cf90366f56bbb42 Mon Sep 17 00:00:00 2001 From: AdrienClairembault Date: Thu, 16 Apr 2026 16:42:21 +0200 Subject: [PATCH] Link items to KB article without page reload --- js/modules/Knowbase/ArticleController.js | 31 ++++- js/modules/Knowbase/LinkItemFormController.js | 107 +++++++++++++++++ .../Knowbase/LinkItemController.php | 113 ++++++++++++++++++ src/KnowbaseItem.php | 2 +- templates/pages/tools/kb/article.html.twig | 45 ++----- .../tools/kb/knowbaseitem_item.html.twig | 6 +- .../tools/kb/related_item_badge.html.twig | 55 +++++++++ .../e2e/specs/Knowbase/related_items.spec.ts | 9 +- 8 files changed, 328 insertions(+), 40 deletions(-) create mode 100644 js/modules/Knowbase/LinkItemFormController.js create mode 100644 src/Glpi/Controller/Knowbase/LinkItemController.php create mode 100644 templates/pages/tools/kb/related_item_badge.html.twig diff --git a/js/modules/Knowbase/ArticleController.js b/js/modules/Knowbase/ArticleController.js index ef0ae333017..e5f7377e6dc 100644 --- a/js/modules/Knowbase/ArticleController.js +++ b/js/modules/Knowbase/ArticleController.js @@ -34,6 +34,7 @@ import { get, post } from "/js/modules/Ajax.js"; import { DocumentLinkController } from "/js/modules/Knowbase/DocumentLinkController.js"; +import { LinkItemFormController } from "/js/modules/Knowbase/LinkItemFormController.js"; import { GlpiKnowbaseArticleSidePanelController } from "/js/modules/Knowbase/ArticleSidePanelController.js"; const EditorActionType = Object.freeze({ @@ -220,6 +221,11 @@ export class GlpiKnowbaseArticleController this.#container.addEventListener('documents:uploaded', (e) => { this.#onDocumentsUploaded(e.detail.documents ?? []); }); + + // Handle items linked without page reload + this.#container.addEventListener('item:linked', (e) => { + this.#onItemLinked(e.detail.item ?? null); + }); } #initDiffListeners() @@ -425,6 +431,9 @@ export class GlpiKnowbaseArticleController url: `${CFG_GLPI.root_doc}/Knowbase/${id}/${key}`, title: title || '', dialogclass: 'modal-lg', + show: key === 'LinkItemModal' ? (e) => { + new LinkItemFormController(e.target.closest('.modal')); + } : () => {}, }); } @@ -513,9 +522,11 @@ export class GlpiKnowbaseArticleController if (meta_link) { const current = parseInt(meta_link.textContent, 10) || 0; const updated = Math.max(0, current + delta); + const meta_container = meta_link.closest('[data-kb-related-items-count-container]'); if (updated === 0) { - meta_link.closest('.d-flex')?.remove(); + meta_container.classList.add('d-none'); } else { + meta_container.classList.remove('d-none'); const label = _n('%s related item', '%s related items', updated).replace('%s', updated); meta_link.textContent = label; } @@ -576,6 +587,24 @@ export class GlpiKnowbaseArticleController this.#updateDocumentCount(documents.length); } + /** + * @param {{html: string}|null} item + */ + #onItemLinked(item) + { + if (!item) { + return; + } + + // Insert new badge + const badges_container = this.#container.querySelector('[data-glpi-kb-related-items-list]'); + badges_container.insertAdjacentHTML('beforeend', item.html); + badges_container.classList.remove('d-none'); + + // Update counters + this.#updateRelatedItemCount(1); + } + /** * Initialize edit button listeners (editor is loaded lazily on first edit) */ diff --git a/js/modules/Knowbase/LinkItemFormController.js b/js/modules/Knowbase/LinkItemFormController.js new file mode 100644 index 00000000000..2c6c6e8eaf5 --- /dev/null +++ b/js/modules/Knowbase/LinkItemFormController.js @@ -0,0 +1,107 @@ +/** + * --------------------------------------------------------------------- + * + * GLPI - Gestionnaire Libre de Parc Informatique + * + * http://glpi-project.org + * + * @copyright 2015-2026 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 bootstrap, glpi_toast_info */ + +import { post } from '/js/modules/Ajax.js'; + +/** + * Controller for the "Link to another item" form inside a glpi_ajax_dialog. + * + * Submits the form via JS to the LinkItem endpoint without a page reload, + * dispatches an `item:linked` custom event so the article controller can + * insert the new badge, closes the modal, and shows a success toast. + */ +export class LinkItemFormController +{ + /** @type {HTMLFormElement} */ + #form; + + /** @type {HTMLElement} */ + #modal; + + /** @type {number} */ + #kb_id; + + /** + * @param {HTMLElement} modal - The Bootstrap modal element + */ + constructor(modal) + { + this.#modal = modal; + this.#form = modal.querySelector('[data-glpi-kb-link-item-form]'); + + if (!this.#form) { + return; + } + + this.#kb_id = parseInt(this.#form.dataset.glpiKbId, 10); + this.#bindEvents(); + } + + #bindEvents() + { + this.#form.addEventListener('submit', (e) => { + e.preventDefault(); + this.#submit(); + }); + } + + async #submit() + { + const itemtype = this.#form.querySelector('[name="itemtype"]').value; + const items_id = parseInt( + this.#form.querySelector('[name="items_id"]').value, + ); + + if (!itemtype || !items_id) { + return; + } + + const response = await post(`Knowbase/${this.#kb_id}/LinkItem`, { itemtype, items_id }); + const body = await response.json(); + + // Notify the article controller + const article = document.querySelector('[data-glpi-knowbase-article]'); + if (article) { + article.dispatchEvent(new CustomEvent('item:linked', { + bubbles: true, + detail: { item: body.item ?? null }, + })); + } + + // Close the dialog + bootstrap.Modal.getInstance(this.#modal)?.hide(); + + glpi_toast_info(__('Item linked successfully')); + } +} diff --git a/src/Glpi/Controller/Knowbase/LinkItemController.php b/src/Glpi/Controller/Knowbase/LinkItemController.php new file mode 100644 index 00000000000..faccf7e6bc4 --- /dev/null +++ b/src/Glpi/Controller/Knowbase/LinkItemController.php @@ -0,0 +1,113 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Controller\Knowbase; + +use CommonDBTM; +use Glpi\Application\View\TemplateRenderer; +use Glpi\Controller\AbstractController; +use Glpi\Controller\CrudControllerTrait; +use Glpi\Exception\Http\BadRequestHttpException; +use KnowbaseItem; +use KnowbaseItem_Item; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Attribute\Route; + +use function Safe\json_decode; + +final class LinkItemController extends AbstractController +{ + use CrudControllerTrait; + + #[Route( + "/Knowbase/{id}/LinkItem", + name: "knowbase_link_item", + methods: ["POST"], + requirements: ['id' => '\d+'] + )] + public function __invoke(int $id, Request $request): JsonResponse + { + $kb = KnowbaseItem::getById($id); + if (!$kb) { + throw new BadRequestHttpException(); + } + + $data = json_decode($request->getContent(), true); + $itemtype = (string) ($data['itemtype'] ?? ''); + $items_id = (int) ($data['items_id'] ?? 0); + + if ( + $itemtype === '' + || $items_id <= 0 + || !is_a($itemtype, CommonDBTM::class, true) + ) { + throw new BadRequestHttpException(); + } + + $link = $this->add(KnowbaseItem_Item::class, [ + 'knowbaseitems_id' => $id, + 'itemtype' => $itemtype, + 'items_id' => $items_id, + ]); + + $linked_item = new $itemtype(); + if (!$linked_item->getFromDB($items_id)) { + throw new BadRequestHttpException(); + } + + $icon_and_color = KnowbaseItem::getRelatedItemIconAndColor($itemtype); + + $item_data = [ + 'id' => $link->getID(), + 'items_id' => $items_id, + 'itemtype' => $itemtype, + 'name' => $linked_item->getName(), + 'link_url' => $linked_item->getLinkURL(), + 'type_name' => $itemtype::getTypeName(1), + 'icon_class' => $icon_and_color['icon_class'], + 'color_class' => $icon_and_color['color_class'], + ]; + + $html = TemplateRenderer::getInstance()->render( + 'pages/tools/kb/related_item_badge.html.twig', + [ + 'item' => $item_data, + 'can_edit' => true, + ] + ); + + return new JsonResponse(['item' => ['html' => $html]]); + } +} diff --git a/src/KnowbaseItem.php b/src/KnowbaseItem.php index b0397c8690b..38daed11701 100644 --- a/src/KnowbaseItem.php +++ b/src/KnowbaseItem.php @@ -1179,7 +1179,7 @@ public static function getDocumentIconAndColor(string $extension): array * @param class-string $itemtype The itemtype class name * @return array{icon_class: string, color_class: string, sector: string} */ - private static function getRelatedItemIconAndColor(string $itemtype): array + public static function getRelatedItemIconAndColor(string $itemtype): array { $sector = Html::getMenuSectorForItemtype($itemtype); diff --git a/templates/pages/tools/kb/article.html.twig b/templates/pages/tools/kb/article.html.twig index 9c719b14f52..1b5fc405926 100644 --- a/templates/pages/tools/kb/article.html.twig +++ b/templates/pages/tools/kb/article.html.twig @@ -259,14 +259,12 @@ - {% if related_items_count > 0 %} - - {% endif %} + {# Visibility dates indicator #} {% if (begin_date is defined or end_date is defined) and mode == 'edit' and can_edit %} @@ -544,35 +542,14 @@ {# Related Items Tab Pane #}
- {% if related_items|length > 0 %} -
+ - {% endif %} {% if can_link_items %} + {% endif %} + diff --git a/tests/e2e/specs/Knowbase/related_items.spec.ts b/tests/e2e/specs/Knowbase/related_items.spec.ts index d1aa9496f9d..bff282e61cd 100644 --- a/tests/e2e/specs/Knowbase/related_items.spec.ts +++ b/tests/e2e/specs/Knowbase/related_items.spec.ts @@ -113,12 +113,15 @@ test('Can link an item to a knowledge base article', async ({ page, profile, api // Submit the form await modal.getByRole('button', { name: 'Add' }).click(); + // Modal should close and a success toast should appear + await expect(modal).toBeHidden(); + await expect(kb.getAlert('Item linked successfully')).toBeVisible(); + // Counter should be updated to 1 const updated_tab = page.getByRole('tab', { name: /Related items/ }); await expect(updated_tab).toContainText('1'); - // Switch to the Related items tab and check the chip is visible - await updated_tab.click(); + // The chip should be visible in the list await expect(page.getByTestId('related-item-chip').filter({ hasText: computer_name })).toBeVisible(); }); @@ -155,5 +158,5 @@ test('Can unlink an item', async ({ page, profile, api }) => { await kb.getButton('Unlink item').click(); await kb.getButton('Unlink').click(); await expect(page.getByText(computer_name)).not.toBeAttached(); - await expect(page.getByTestId('related-items-count')).not.toBeAttached(); + await expect(page.getByTestId('related-items-count')).toBeHidden(); });