Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion js/modules/Knowbase/ArticleController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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'));
} : () => {},
});
}

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)
*/
Expand Down
107 changes: 107 additions & 0 deletions js/modules/Knowbase/LinkItemFormController.js
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*
* ---------------------------------------------------------------------
*/

/* 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'));
}
}
113 changes: 113 additions & 0 deletions src/Glpi/Controller/Knowbase/LinkItemController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

/**
* ---------------------------------------------------------------------
*
* 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 <https://www.gnu.org/licenses/>.
*
* ---------------------------------------------------------------------
*/

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]]);
}
}
2 changes: 1 addition & 1 deletion src/KnowbaseItem.php
Original file line number Diff line number Diff line change
Expand Up @@ -1179,7 +1179,7 @@ public static function getDocumentIconAndColor(string $extension): array
* @param class-string<CommonDBTM> $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);

Expand Down
45 changes: 11 additions & 34 deletions templates/pages/tools/kb/article.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -259,14 +259,12 @@
</a>
</div>

{% if related_items_count > 0 %}
<div class="d-flex align-items-center">
<i class="ti ti-link me-1 fs-3"></i>
<a href="#kb-documents" class="text-muted" data-kb-related-items-count data-testid="related-items-count">
{{ _n("%s related item", "%s related items", related_items_count)|format(related_items_count) }}
</a>
</div>
{% endif %}
<div class="d-flex align-items-center {{ related_items_count == 0 ? 'd-none' : '' }}" data-kb-related-items-count-container>
<i class="ti ti-link me-1 fs-3"></i>
<a href="#kb-documents" class="text-muted" data-kb-related-items-count data-testid="related-items-count">
{{ _n("%s related item", "%s related items", related_items_count)|format(related_items_count) }}
</a>
</div>

{# Visibility dates indicator #}
{% if (begin_date is defined or end_date is defined) and mode == 'edit' and can_edit %}
Expand Down Expand Up @@ -544,35 +542,14 @@

{# Related Items Tab Pane #}
<div class="tab-pane fade" id="kb-items-tab" role="tabpanel">
{% if related_items|length > 0 %}
<div class="d-flex flex-wrap gap-2 mb-2">
<div class="d-flex flex-wrap gap-2 mb-2 {{ related_items|length == 0 ? 'd-none' : '' }}" data-glpi-kb-related-items-list>
{% for item in related_items %}
<span
class="kb-item-badge badge bg-light text-dark d-flex align-items-center gap-2"
data-glpi-item-assoc-id="{{ item.id }}"
data-testid="related-item-chip"
>
<a href="{{ item.link_url }}"
class="d-flex align-items-center gap-2 text-decoration-none"
title="{{ __('%1$s: %2$s')|format(item.type_name, item.name) }}"
>
<i class="{{ item.icon_class }} {{ item.color_class }}"></i>
<span>{{ item.name }}</span>
</a>

{% if can_edit %}
<button
type="button"
class="kb-unlink-item btn-close btn-close-white"
data-glpi-kb-unlink-item="{{ item.id }}"
title="{{ __('Unlink item') }}"
aria-label="{{ __('Unlink item') }}"
></button>
{% endif %}
</span>
{{ include('pages/tools/kb/related_item_badge.html.twig', {
item: item,
can_edit: can_edit,
}, with_context = false) }}
{% endfor %}
</div>
{% endif %}

{% if can_link_items %}
<button type="button" class="btn btn-sm btn-primary mt-2"
Expand Down
6 changes: 5 additions & 1 deletion templates/pages/tools/kb/knowbaseitem_item.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@

{% import 'components/form/basic_inputs_macros.html.twig' as inputs %}

<form method="post" action="{{ 'KnowbaseItem_Item'|itemtype_form_path }}" class="kb-link-item-form">
<form
class="kb-link-item-form"
data-glpi-kb-link-item-form
data-glpi-kb-id="{{ item.getID() }}"
>
{% if get_class(item) == 'KnowbaseItem' %}
{{ inputs.hidden('knowbaseitems_id', item.getID()) }}
{% else %}
Expand Down
Loading
Loading