diff --git a/install/migrations/update_11.0.x_to_12.0.0/knowbaseitem_folds.php b/install/migrations/update_11.0.x_to_12.0.0/knowbaseitem_folds.php index 13445e385eb..0edae6ba54b 100644 --- a/install/migrations/update_11.0.x_to_12.0.0/knowbaseitem_folds.php +++ b/install/migrations/update_11.0.x_to_12.0.0/knowbaseitem_folds.php @@ -36,10 +36,10 @@ * @var Migration $migration */ -// Per-user list of KB aside articles the user has collapsed (folded). +// Per-user list of KB aside articles the user has unfolded. $migration->addField( 'glpi_users', - 'folded_knowbaseitems', + 'unfolded_knowbaseitems', 'json DEFAULT NULL', ['after' => 'itil_layout'] ); diff --git a/install/mysql/glpi-empty.sql b/install/mysql/glpi-empty.sql index f330e70fef0..2451a2654b7 100644 --- a/install/mysql/glpi-empty.sql +++ b/install/mysql/glpi-empty.sql @@ -8095,7 +8095,7 @@ CREATE TABLE `glpi_users` ( `savedsearches_pinned` text, `timeline_order` char(20) DEFAULT NULL, `itil_layout` text, - `folded_knowbaseitems` json, + `unfolded_knowbaseitems` json, `richtext_layout` char(20) DEFAULT NULL, `set_default_requester` tinyint DEFAULT NULL, `lock_autolock_mode` tinyint DEFAULT NULL, diff --git a/js/modules/Knowbase/AsideController.js b/js/modules/Knowbase/AsideController.js index 69beef4d4fb..6618948496b 100644 --- a/js/modules/Knowbase/AsideController.js +++ b/js/modules/Knowbase/AsideController.js @@ -57,6 +57,14 @@ export class GlpiKnowbaseAsideController */ #search_request_id = 0; + /** + * Children requests by article id, so a branch is only fetched once even if + * the reader folds and unfolds it repeatedly. + * + * @type {Map>} + */ + #children_cache = new Map(); + /** * Whether the favorites section was hidden on initial server render. * Used to restore the correct state after clearing the search. @@ -138,21 +146,82 @@ export class GlpiKnowbaseAsideController * * @param {HTMLElement} node * @param {boolean} collapsed + * @returns {Promise} */ - #setCollapsed(node, collapsed) + async #setCollapsed(node, collapsed) { - node.toggleAttribute('data-glpi-kb-aside-category-collapsed', collapsed); + const id = node.dataset.glpiKbArticleId; + + // The same article can be rendered more than once: under each of its + // parents, and again in the search results while the rendered tree is + // kept hidden. They all share one fold state, so every copy gets the + // very same treatment. + const nodes = id ? this.#aside.querySelectorAll( + `[data-glpi-kb-aside-category][data-glpi-kb-article-id="${CSS.escape(id)}"]`, + ) : [node]; + + const loading = []; + for (const twin of nodes) { + twin.toggleAttribute('data-glpi-kb-aside-category-collapsed', collapsed); + + // `:scope >` on the header is required: without it we would reach + // the toggle of a nested article instead of this node's own one. + const toggle = twin.querySelector( + ':scope > [data-glpi-kb-aside-category-header] [data-glpi-kb-aside-category-toggle]' + ); + // A childless node has no toggle to update (it is still collapsible, + // so that a child created below lands in a visible list). + toggle?.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + + if (!collapsed) { + // A single fetch feeds them all: `#loadChildren()` caches the + // pending request per article id. + loading.push(this.#loadChildren(twin)); + } + } - // `:scope >` on the header is required: without it we would reach the - // toggle of a nested article instead of this node's own one. - const toggle = node.querySelector( - ':scope > [data-glpi-kb-aside-category-header] [data-glpi-kb-aside-category-toggle]' - ); - // A childless node has no toggle to update (it is still collapsible, so - // that a child created below lands in a visible list). - toggle?.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + this.#persistArticleFold(id, collapsed); - this.#persistArticleFold(node.dataset.glpiKbArticleId, collapsed); + await Promise.all(loading); + } + + /** + * Fill in the children of a node the reader just unfolded, if the tree was + * rendered without them. + * + * @param {HTMLElement} node + */ + async #loadChildren(node) + { + // `:scope >` is required: a nested node has a list of its own. + const list = node.querySelector(':scope > ul[data-glpi-kb-children-unloaded]'); + if (!list) { + return; + } + // Claim it right away, so a second unfold does not fetch it again. + list.removeAttribute('data-glpi-kb-children-unloaded'); + + const id = parseInt(node.dataset.glpiKbArticleId); + if (!this.#children_cache.has(id)) { + const current_id = this.#aside + .querySelector('[data-glpi-kb-aside-tree] [data-glpi-kb-article-current]') + ?.dataset.glpiKbArticleId ?? ''; + this.#children_cache.set( + id, + get( + `Knowbase/Aside/Article/${encodeURIComponent(id)}/Children` + + `?current_id=${encodeURIComponent(current_id)}`, + ).then((response) => response.text()), + ); + } + + try { + list.innerHTML = await this.#children_cache.get(id); + } catch { + // Drop the cached rejection and let a later unfold retry. + this.#children_cache.delete(id); + list.setAttribute('data-glpi-kb-children-unloaded', ''); + } } #initToggle() @@ -307,11 +376,10 @@ export class GlpiKnowbaseAsideController /** * @param {HTMLElement} add_button */ - #openCreateInput(add_button) + async #openCreateInput(add_button) { const header = add_button.closest('[data-glpi-kb-aside-category-header]'); const node = header.closest('[data-glpi-kb-aside-category]'); - const list = node.querySelector(':scope > ul'); const parent_id = Number(add_button.dataset.glpiKbAsideCategoryAdd) || 0; // The list is hidden while the node is collapsed, so the input below @@ -321,9 +389,12 @@ export class GlpiKnowbaseAsideController // article: were the parent still folded on that reload, the article the // user just created would be hidden. if (node.hasAttribute('data-glpi-kb-aside-category-collapsed')) { - this.#setCollapsed(node, false); + await this.#setCollapsed(node, false); } + // Looked up after the expansion above, which may have refilled it. + const list = node.querySelector(':scope > ul'); + // Only one inline input at a time across the whole tree. const existing = this.#aside.querySelector('[data-glpi-kb-aside-create-row]'); if (existing) { @@ -461,6 +532,8 @@ export class GlpiKnowbaseAsideController const tree = this.#aside.querySelector('[data-glpi-kb-aside-tree]'); const favorites = this.#aside.querySelector('[data-glpi-kb-aside-favorites]'); + const request_id = ++this.#search_request_id; + // Search criteria was removed, show all items again if (value.trim() === '') { this.#showAllTreeItems(tree); @@ -469,18 +542,45 @@ export class GlpiKnowbaseAsideController } // Send request to backend - const request_id = ++this.#search_request_id; + const current_id = this.#aside + .querySelector('[data-glpi-kb-article-current]')?.dataset.glpiKbArticleId ?? ''; const response = await get( - `Knowbase/Aside/Search?contains=${encodeURIComponent(value)}`, + `Knowbase/Aside/Search?contains=${encodeURIComponent(value)}` + + `¤t_id=${encodeURIComponent(current_id)}`, ); - const matching_ids = new Set(await response.json()); + const { ids, html } = await response.json(); if (request_id !== this.#search_request_id) { return; } // Apply results - this.#filterTree(tree, matching_ids); - this.#filterFavorites(favorites, matching_ids); + this.#showTreeResults(tree, html); + this.#filterFavorites(favorites, new Set(ids)); + } + + /** + * Replace the tree with the server-rendered search results. The rendered + * tree is kept in place (hidden) so clearing the search restores it without + * a round trip. + * + * @param {HTMLElement} tree + * @param {string} html + */ + #showTreeResults(tree, html) + { + const rendered = tree.querySelector(':scope > ul.kb-tree'); + rendered?.setAttribute('data-glpi-kb-search-hidden', ''); + + let results = tree.querySelector(':scope > [data-glpi-kb-aside-tree-results]'); + if (!results) { + results = document.createElement('div'); + results.setAttribute('data-glpi-kb-aside-tree-results', ''); + rendered ? rendered.after(results) : tree.prepend(results); + } + results.innerHTML = html; + + const no_results = tree.querySelector('[data-glpi-kb-aside-no-results]'); + no_results.hidden = results.querySelector('[data-glpi-kb-article-id]') !== null; } /** @@ -490,6 +590,8 @@ export class GlpiKnowbaseAsideController */ #showAllTreeItems(tree) { + tree.querySelector(':scope > [data-glpi-kb-aside-tree-results]')?.remove(); + for (const el of tree.querySelectorAll('[data-glpi-kb-search-hidden]')) { el.removeAttribute('data-glpi-kb-search-hidden'); } @@ -565,62 +667,6 @@ export class GlpiKnowbaseAsideController } } - /** - * Filter the tree to only show articles whose IDs are in matching_ids. - * Articles (leaf or with children) with no visible descendant are hidden - * recursively. - * - * @param {HTMLElement} tree - * @param {Set} matching_ids - */ - #filterTree(tree, matching_ids) - { - let any_visible = false; - - for (const article of tree.querySelectorAll(':scope > ul > [data-glpi-kb-article-id]')) { - if (this.#filterArticle(article, matching_ids)) { - any_visible = true; - } - } - - // Show information message if no results are found - const no_results = tree.querySelector('[data-glpi-kb-aside-no-results]'); - no_results.hidden = any_visible; - } - - /** - * Recursively determines whether an article row (leaf or with children) - * should stay visible — either its own title/id matched the search, or - * one of its descendants did — and hides/shows it (and recurses into its - * children, if any) in place. - * - * @param {HTMLElement} article_el - * @param {Set} matching_ids - * @returns {boolean} Whether this article or any of its descendants match. - */ - #filterArticle(article_el, matching_ids) - { - const id = parseInt(article_el.dataset.glpiKbArticleId); - let visible = matching_ids.has(id); - - const ul = article_el.querySelector(':scope > ul'); - if (ul) { - for (const child of ul.querySelectorAll(':scope > [data-glpi-kb-article-id]')) { - if (this.#filterArticle(child, matching_ids)) { - visible = true; - } - } - } - - if (visible) { - article_el.removeAttribute('data-glpi-kb-search-hidden'); - } else { - article_el.setAttribute('data-glpi-kb-search-hidden', ''); - } - - return visible; - } - /** * Wire up the per-article kebab menu actions (add to favorites, add to FAQ, * delete). Clicks are delegated so entries added later (e.g. a cloned @@ -646,16 +692,23 @@ export class GlpiKnowbaseAsideController } }); - // Prefetch the menu content as soon as the row is hovered or focused, so - // it is ready by the time the user opens the kebab (no visible latency). - const prefetch = (e) => { + // Create the row's menu and prefetch its content as soon as the row is + // hovered or focused, so both are ready by the time the user opens the + // kebab (no visible latency). + const prepare = (e) => { const line = e.target.closest('.article[data-glpi-kb-article-id]'); if (line && this.#aside.contains(line)) { + this.#ensureActionsMenu(line); this.#populateMenus(parseInt(line.dataset.glpiKbArticleId)); } }; - this.#aside.addEventListener('mouseover', prefetch); - this.#aside.addEventListener('focusin', prefetch); + this.#aside.addEventListener('mouseover', prepare); + this.#aside.addEventListener('focusin', prepare); + // Safety net for opens that skip hover and focus (touch, synthetic + // clicks): the menu has to exist before Bootstrap looks it up, and the + // capture phase runs before its own delegated click handler. + this.#aside.addEventListener('pointerdown', prepare); + this.#aside.addEventListener('click', prepare, true); // Fallback for opens that outran the prefetch (touch, instant clicks, // keyboard): make sure the content is loaded when the menu opens. @@ -667,6 +720,31 @@ export class GlpiKnowbaseAsideController }); } + /** + * Create an article row's kebab menu element, unless it already has one. + * + * The tree only renders the menu triggers: a large knowledge base would + * otherwise carry thousands of identical, never-opened menus. The menu is + * cloned from the template the aside renders once, see + * `render_actions_menu_lazy()`. + * + * @param {HTMLElement} line + */ + #ensureActionsMenu(line) + { + // Scoped to the row itself: a row nests its child rows, whose own + // triggers must not be confused with it. + const dropdown = line.querySelector(':scope > .article-line > .dropdown'); + if (!dropdown || dropdown.querySelector(':scope > [data-glpi-kb-actions-menu]')) { + return; + } + + const template = this.#aside.querySelector('[data-glpi-kb-actions-menu-template]'); + if (template) { + dropdown.append(template.content.cloneNode(true)); + } + } + /** * Fetch (once) and inject the kebab menu items for an article into every * not-yet-populated menu bearing that id (tree + favorites). diff --git a/src/Glpi/Controller/Knowbase/AsideArticleChildrenController.php b/src/Glpi/Controller/Knowbase/AsideArticleChildrenController.php new file mode 100644 index 00000000000..caa26bd59c9 --- /dev/null +++ b/src/Glpi/Controller/Knowbase/AsideArticleChildrenController.php @@ -0,0 +1,78 @@ +. + * + * --------------------------------------------------------------------- + */ + +namespace Glpi\Controller\Knowbase; + +use Glpi\Controller\AbstractController; +use Glpi\Exception\Http\AccessDeniedHttpException; +use Glpi\Knowbase\Aside\Builder; +use KnowbaseItem; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +/** + * Renders the children of a single knowledge base article, for the aside tree. + * + * The tree is folded by default and renders a folded article without its + * children: rendering the whole knowledge base up front is what makes a large + * one expensive. This fills a branch in when the reader unfolds it. + */ +final class AsideArticleChildrenController extends AbstractController +{ + #[Route( + "/Knowbase/Aside/Article/{id}/Children", + name: "knowbase_aside_article_children", + requirements: [ + 'id' => '\d+', + ], + methods: 'GET', + )] + public function __invoke(int $id, Request $request): Response + { + if (!KnowbaseItem::canView()) { + throw new AccessDeniedHttpException(); + } + + // Visibility is applied by the builder, which returns nothing for an + // article the current user may not see. + $children = (new Builder($request->query->getInt('current_id')))->buildChildren($id); + + return $this->render('pages/tools/kb/aside_children.html.twig', [ + 'children' => $children, + 'can_create' => KnowbaseItem::canCreate(), + 'show_actions' => KnowbaseItem::canShowAsideActions(), + ]); + } +} diff --git a/src/Glpi/Controller/Knowbase/AsideArticleFoldController.php b/src/Glpi/Controller/Knowbase/AsideArticleFoldController.php index a694bc80842..2832f6a3601 100644 --- a/src/Glpi/Controller/Knowbase/AsideArticleFoldController.php +++ b/src/Glpi/Controller/Knowbase/AsideArticleFoldController.php @@ -72,9 +72,9 @@ public function __invoke(int $id, Request $request): Response return new Response(); } - KnowbaseItem::setFoldedForCurrentUser( + KnowbaseItem::setUnfoldedForCurrentUser( id: $id, - folded: (bool) $collapsed + unfolded: !((bool) $collapsed) ); return new Response(); // OK diff --git a/src/Glpi/Controller/Knowbase/AsideSearchController.php b/src/Glpi/Controller/Knowbase/AsideSearchController.php index 98b52c513cc..309833e1728 100644 --- a/src/Glpi/Controller/Knowbase/AsideSearchController.php +++ b/src/Glpi/Controller/Knowbase/AsideSearchController.php @@ -34,9 +34,11 @@ namespace Glpi\Controller\Knowbase; +use Glpi\Application\View\TemplateRenderer; use Glpi\Controller\AbstractController; use Glpi\Exception\Http\AccessDeniedHttpException; use Glpi\Exception\Http\BadRequestHttpException; +use Glpi\Knowbase\Aside\Builder; use KnowbaseItem; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -68,9 +70,7 @@ public function __invoke(Request $request): JsonResponse // Get article IDs that match this filter $criteria = KnowbaseItem::getListRequest(['contains' => $contains], 'search'); - // The response is only used for membership tests, so neither the article - // columns (`glpi_knowbaseitems.*` includes `answer`) nor the relevance - // ordering are needed. + // Only the matching ids are needed here $criteria['SELECT'] = [KnowbaseItem::getTableField('id')]; unset($criteria['ORDERBY']); @@ -79,6 +79,21 @@ public function __invoke(Request $request): JsonResponse $ids[] = (int) $data['id']; } - return new JsonResponse($ids); + // Render the filtered tree + $tree = (new Builder($request->query->getInt('current_id')))->buildSearchTree($ids); + + return new JsonResponse([ + // Consumed by the favorites section, which filters the rows it + // already holds. + 'ids' => $ids, + 'html' => TemplateRenderer::getInstance()->render( + 'pages/tools/kb/aside_tree.html.twig', + [ + 'tree' => $tree, + 'can_create' => KnowbaseItem::canCreate(), + 'show_actions' => KnowbaseItem::canShowAsideActions(), + ] + ), + ]); } } diff --git a/src/Glpi/Knowbase/Aside/Article.php b/src/Glpi/Knowbase/Aside/Article.php index 3fb060f2050..db2b9abca0f 100644 --- a/src/Glpi/Knowbase/Aside/Article.php +++ b/src/Glpi/Knowbase/Aside/Article.php @@ -39,6 +39,13 @@ final class Article /** @var Article[] */ private array $children = []; + /** + * @param bool $has_children Whether the article has children to show, + * whether or not they are loaded. + * @param bool $children_loaded Whether `getChildren()` holds them. A folded + * article renders without its children, which + * the aside fetches when the reader unfolds it. + */ public function __construct( public readonly int $id, public readonly string $title, @@ -46,6 +53,8 @@ public function __construct( public readonly string $link, public readonly bool $is_current = false, public readonly bool $collapsed = false, + public readonly bool $has_children = false, + public readonly bool $children_loaded = true, ) {} public function addChild(self $child): void @@ -61,6 +70,6 @@ public function getChildren(): array public function hasChildren(): bool { - return $this->children !== []; + return $this->has_children || $this->children !== []; } } diff --git a/src/Glpi/Knowbase/Aside/Builder.php b/src/Glpi/Knowbase/Aside/Builder.php index cbf036bef3f..b5a626fc975 100644 --- a/src/Glpi/Knowbase/Aside/Builder.php +++ b/src/Glpi/Knowbase/Aside/Builder.php @@ -56,77 +56,245 @@ final class Builder 'glpi_knowbaseitems.illustration', ]; - /** @var array */ + /** @var array> Visible articles, id => row */ + private array $data = []; + + /** @var array parent_id => visible child ids */ + private array $children_of = []; + + /** @var array child_id => visible parent ids */ + private array $parents_of = []; + + /** @var array Visible articles with no visible parent */ + private array $roots = []; + + /** + * Articles that render folded, as a lookup map. + * + * The knowledge base is folded by default. + * An article is unfolded when it is a root (the entry point of the tree), + * when the user unfolded it, or when it leads to the article being read. + * + * @var array + */ private array $folded_ids_lookup_map = []; + /** + * Articles to render, when the tree is restricted to a subset (search). + * Null renders the whole tree. + * + * @var array|null + */ + private ?array $rendered_ids = null; + + private bool $hierarchy_loaded = false; + public function __construct(private readonly int $current_id = 0) {} public function buildTree(): Tree { + $this->loadHierarchy(); + $this->rendered_ids = null; + $this->folded_ids_lookup_map = $this->computeFoldedIds(); + + $tree = new Tree(); + foreach (array_keys($this->roots) as $id) { + $tree->addArticle($this->buildArticle($id, [])); + } + + return $tree; + } + + /** + * Tree restricted to the given articles and to the branches leading to + * them, as used by the aside search. + * + * Ancestors are included so a match is never orphaned, and nothing is + * folded: a match has to be visible without the reader unfolding its + * ancestors first. Descendants of a match are left out unless they match + * too, which is what the search filter it replaces did. + * + * @param int[] $matching_ids + */ + public function buildSearchTree(array $matching_ids): Tree + { + $this->loadHierarchy(); + $this->rendered_ids = $this->withAncestors(array_intersect_key( + array_fill_keys(array_map('intval', $matching_ids), true), + $this->data + )); + $this->folded_ids_lookup_map = []; + + $tree = new Tree(); + foreach (array_keys($this->roots) as $id) { + if ($this->isRendered($id)) { + $tree->addArticle($this->buildArticle($id, [])); + } + } + + return $tree; + } + + /** + * Children of a single article, as the aside fetches them when the reader + * unfolds it. Empty when the article is not visible to the current user. + * + * @return Article[] + */ + public function buildChildren(int $parent_id): array + { + $this->loadHierarchy(); + if (!isset($this->data[$parent_id])) { + return []; + } + $this->rendered_ids = null; + $this->folded_ids_lookup_map = $this->computeFoldedIds(); + + $children = []; + foreach ($this->children_of[$parent_id] ?? [] as $child_id) { + $children[] = $this->buildArticle($child_id, [$parent_id => true]); + } + + return $children; + } + + /** + * Load the visible articles and the hierarchy between them, once. + */ + private function loadHierarchy(): void + { + /** @var \DBmysql $DB */ global $DB; - // Articles the current user has collapsed, restored on each render. - $this->folded_ids_lookup_map = array_fill_keys(KnowbaseItem::getFoldedIdsForCurrentUser(), true); + if ($this->hierarchy_loaded) { + return; + } + $this->hierarchy_loaded = true; // 1) All articles the current user may see (visibility applied). $criteria = KnowbaseItem::getListRequest([], 'browse'); $criteria['SELECT'] = self::LIST_COLUMNS; - $rows = $DB->request($criteria); - $data = []; // id => row - foreach ($rows as $row) { - $data[(int) $row['id']] = $row; + foreach ($DB->request($criteria) as $row) { + $this->data[(int) $row['id']] = $row; } - if ($data === []) { - return new Tree(); + if ($this->data === []) { + return; } - $visible_ids = array_keys($data); - // 2) Visible parent -> [visible children] adjacency, and child -> has a visible parent? - $children_of = []; // parent_id => int[] child ids - $has_visible_parent = []; // child_id => true + // 2) Visible parent-> [visible children] adjacency, and the reverse. + $has_visible_parent = []; foreach ($DB->request(['FROM' => KnowbaseItem_KnowbaseItem::getTable()]) as $link) { $child = (int) $link['knowbaseitems_id']; $parent = (int) $link['knowbaseitems_id_parent']; - if (!isset($data[$child], $data[$parent])) { + if (!isset($this->data[$child], $this->data[$parent])) { continue; // one of the ends is not visible to the current user } - $children_of[$parent][] = $child; + $this->children_of[$parent][] = $child; + $this->parents_of[$child][] = $parent; $has_visible_parent[$child] = true; } // 3) Roots = visible articles with no visible parent (promote-to-root). - $tree = new Tree(); - foreach ($visible_ids as $id) { + foreach (array_keys($this->data) as $id) { if (!isset($has_visible_parent[$id])) { - $tree->addArticle($this->buildArticle($id, $data, $children_of, [])); + $this->roots[$id] = true; } } - return $tree; } /** - * @param array> $data - * @param array $children_of - * @param array $ancestors visited guard (DAG, but defensive) + * @param array $ancestors Visited guard (DAG, but defensive) */ - private function buildArticle(int $id, array $data, array $children_of, array $ancestors): Article + private function buildArticle(int $id, array $ancestors): Article { - $row = $data[$id]; + $row = $this->data[$id]; + $folded = $this->rendered_ids === null && isset($this->folded_ids_lookup_map[$id]); + + $ancestors[$id] = true; + $children = []; + foreach ($this->children_of[$id] ?? [] as $child_id) { + if (isset($ancestors[$child_id]) || !$this->isRendered($child_id)) { + continue; // cycles are forbidden by writes; guard defensively + } + $children[] = $child_id; + } + $article = new Article( id: $id, title: $row['name'] ?? '', illustration: $row['illustration'] ?? '', link: KnowbaseItem::getFormURLWithID($id), is_current: $this->current_id > 0 && $id === $this->current_id, - collapsed: isset($this->folded_ids_lookup_map[$id]), + collapsed: $folded, + has_children: $children !== [], + children_loaded: !$folded, ); - $ancestors[$id] = true; - foreach ($children_of[$id] ?? [] as $child_id) { - if (isset($ancestors[$child_id])) { - continue; // defensive against cycles (writes forbid them) + + if (!$folded) { + foreach ($children as $child_id) { + $article->addChild($this->buildArticle($child_id, $ancestors)); } - $article->addChild($this->buildArticle($child_id, $data, $children_of, $ancestors)); } + return $article; } + + /** + * Resolve the fold state of every visible article, see + * `$folded_ids_lookup_map`. + * + * @return array + */ + private function computeFoldedIds(): array + { + $unfolded = array_fill_keys(KnowbaseItem::getUnfoldedIdsForCurrentUser(), true); + + // The branch leading to the article being read is always unfolded, so + // the reader can see where they are. It is not persisted: reading an + // article is not the same as opening a branch for good. + $on_current_branch = $this->current_id > 0 + ? $this->withAncestors([$this->current_id => true]) + : []; + + $folded = []; + foreach (array_keys($this->data) as $id) { + if (isset($this->roots[$id]) || isset($unfolded[$id]) || isset($on_current_branch[$id])) { + continue; + } + $folded[$id] = true; + } + + return $folded; + } + + private function isRendered(int $id): bool + { + return $this->rendered_ids === null || isset($this->rendered_ids[$id]); + } + + /** + * The given articles plus every ancestor leading to them, so a restricted + * tree stays attached to its roots. + * + * @param array $ids + * + * @return array + */ + private function withAncestors(array $ids): array + { + $kept = []; + $to_walk = array_keys($ids); + while ($to_walk !== []) { + $id = array_pop($to_walk); + if (isset($kept[$id])) { + continue; + } + $kept[$id] = true; + foreach ($this->parents_of[$id] ?? [] as $parent) { + $to_walk[] = $parent; + } + } + + return $kept; + } } diff --git a/src/KnowbaseItem.php b/src/KnowbaseItem.php index 491e5a68453..48936a68659 100644 --- a/src/KnowbaseItem.php +++ b/src/KnowbaseItem.php @@ -3415,11 +3415,15 @@ private function getCurrentArticleAndFavorites(int $current_id = 0): array } /** - * Ids of the KB aside articles the current user has collapsed (folded). + * Ids of the aside articles the current user has unfolded. + * + * The knowledge base is folded by default, so this holds what the user + * opened. Articles unfolded because they lead to the article being read are + * not stored, see `Glpi\Knowbase\Aside\Builder`. * * @return int[] */ - public static function getFoldedIdsForCurrentUser(): array + public static function getUnfoldedIdsForCurrentUser(): array { $user_id = Session::getLoginUserID(); if ($user_id === false) { @@ -3431,15 +3435,15 @@ public static function getFoldedIdsForCurrentUser(): array return []; } - $ids = json_decode($user->fields['folded_knowbaseitems'] ?? '[]', true); + $ids = json_decode($user->fields['unfolded_knowbaseitems'] ?? '[]', true); return array_map('intval', array_values(is_array($ids) ? $ids : [])); } /** - * Persist whether an aside article is collapsed (folded) for the current user. + * Persist whether an aside article is unfolded for the current user. */ - public static function setFoldedForCurrentUser(int $id, bool $folded): void + public static function setUnfoldedForCurrentUser(int $id, bool $unfolded): void { $user_id = Session::getLoginUserID(); if ($user_id === false) { @@ -3447,16 +3451,16 @@ public static function setFoldedForCurrentUser(int $id, bool $folded): void } $ids = array_values(array_filter( - self::getFoldedIdsForCurrentUser(), + self::getUnfoldedIdsForCurrentUser(), static fn(int $existing): bool => $existing !== $id, )); - if ($folded) { + if ($unfolded) { $ids[] = $id; } (new User())->update([ - 'id' => $user_id, - 'folded_knowbaseitems' => json_encode($ids), + 'id' => $user_id, + 'unfolded_knowbaseitems' => json_encode($ids), ]); } @@ -3475,14 +3479,6 @@ protected function getLeftSideContent(): ?string return null; } - // Whether to render the per-article dots menu trigger. This is a cheap - // session-level check: the menu content itself (and its per-article - // permission gating) is lazy-loaded on demand, so we never load every - // tree article here just to know if any action is available. - $show_actions = KnowbaseItem_Favorite::canCreate() - || self::canUpdate() - || self::canPurge(); - return TemplateRenderer::getInstance()->render( 'pages/tools/kb/aside.html.twig', [ @@ -3491,8 +3487,23 @@ protected function getLeftSideContent(): ?string 'current_is_favorite' => $current_is_favorite, 'has_other_favorites' => $has_other_favorites, 'can_create' => self::canCreate(), - 'show_actions' => $show_actions, + 'show_actions' => self::canShowAsideActions(), ] ); } + + /** + * Whether the aside renders the per-article dots menu trigger. This is a + * cheap session-level check: the menu content itself (and its per-article + * permission gating) is lazy-loaded on demand, so we never load every tree + * article just to know if any action is available. + * + * Shared with `AsideSearchController`, which renders the same rows. + */ + public static function canShowAsideActions(): bool + { + return KnowbaseItem_Favorite::canCreate() + || self::canUpdate() + || self::canPurge(); + } } diff --git a/templates/pages/tools/kb/_actions_menu.html.twig b/templates/pages/tools/kb/_actions_menu.html.twig index 02167237655..78ab3fe5c8b 100644 --- a/templates/pages/tools/kb/_actions_menu.html.twig +++ b/templates/pages/tools/kb/_actions_menu.html.twig @@ -101,10 +101,15 @@ {% endmacro %} -{# Renders a dots menu whose items are lazy-loaded (on hover/open) by the aside - # controller, so the tree never has to build every article's actions up-front. - # The menu starts with a loading placeholder that gets replaced with the - # fetched items. #} +{# Renders the trigger of a dots menu whose items are lazy-loaded (on + # hover/open) by the aside controller, so the tree never has to build every + # article's actions up-front. + # + # The menu element itself is not rendered here: the aside controller clones it + # from `render_actions_menu_placeholder()` when the row is first hovered, + # focused or pressed. A large tree would otherwise carry thousands of + # identical, never-opened menus. The trigger, on the other hand, stays in the + # markup so it remains exposed to assistive technologies. #} {% macro render_actions_menu_lazy() %} +{% endmacro %} + +{# The menu element cloned into a row by the aside controller, see + # `render_actions_menu_lazy()`. Rendered once per aside; its content is inert + # until cloned, and gets replaced with the fetched items. #} +{% macro render_actions_menu_placeholder() %} + {% endmacro %} diff --git a/templates/pages/tools/kb/_article_row.html.twig b/templates/pages/tools/kb/_article_row.html.twig new file mode 100644 index 00000000000..1ff499fdde3 --- /dev/null +++ b/templates/pages/tools/kb/_article_row.html.twig @@ -0,0 +1,128 @@ +{# + # --------------------------------------------------------------------- + # + # 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 . + # + # --------------------------------------------------------------------- + #} + +{# + # Renders a single article, recursing into its children. + # + # - `can_create` whether to show the "+" (create a child article) action. + # - `show_actions` whether to show the per-article kebab (favorite/FAQ/delete) menu. + # - `add_margin` extra bottom margin on the row (used by the favorites list). + # - `favorite_state` null | 'active' | 'pending' (used by the favorites list; see + # `data-glpi-kb-favorite-current` consumers in AsideController.js). + #} +{% macro render_article(article, can_create = false, show_actions = false, add_margin = false, favorite_state = null) %} + {% import _self as macros %} + {% import "pages/tools/kb/_actions_menu.html.twig" as actions_menu %} + {% set is_node = article.hasChildren or can_create %} +
  • +
    + {% if article.hasChildren %} + + {% endif %} + + + {% if article.illustration %} + {{ render_illustration(article.illustration, 20) }} + {% endif %} + + {{ article.title }} + + {% if can_create %} + + {% endif %} + {% if show_actions %} + {{ actions_menu.render_actions_menu_lazy() }} + {% endif %} +
    + {% if is_node %} + {# A folded article renders without its children; the aside fetches + # them the first time the reader unfolds it. #} +
      + {{ macros.render_rows(article.getChildren, can_create, show_actions) }} +
    + {% endif %} +
  • +{% endmacro %} + +{# + # Renders a list of articles as a tree, as rendered by the aside and by the + # search endpoint (which returns the matching branches to swap in). + #} +{% macro render_tree(articles, can_create = false, show_actions = false) %} + {% import _self as macros %} +
      + {{ macros.render_rows(articles, can_create, show_actions) }} +
    +{% endmacro %} + +{# + # Renders a list of articles as bare rows, without the wrapping list. Used to + # fill in the children of an article that the reader just unfolded. + #} +{% macro render_rows(articles, can_create = false, show_actions = false) %} + {% import _self as macros %} + {% for article in articles %} + {{ macros.render_article(article, can_create, show_actions) }} + {% endfor %} +{% endmacro %} diff --git a/templates/pages/tools/kb/aside.html.twig b/templates/pages/tools/kb/aside.html.twig index c3f7edf679a..d13b34b9871 100644 --- a/templates/pages/tools/kb/aside.html.twig +++ b/templates/pages/tools/kb/aside.html.twig @@ -30,85 +30,15 @@ # --------------------------------------------------------------------- #} -{# - # Renders a single article, recursing into its children. - # - # - `can_create` whether to show the "+" (create a child article) action. - # - `show_actions` whether to show the per-article kebab (favorite/FAQ/delete) menu. - # - `add_margin` extra bottom margin on the row (used by the favorites list). - # - `favorite_state` null | 'active' | 'pending' (used by the favorites list; see - # `data-glpi-kb-favorite-current` consumers in AsideController.js). - #} -{% macro render_article(article, can_create = false, show_actions = false, add_margin = false, favorite_state = null) %} - {% import _self as macros %} - {% import "pages/tools/kb/_actions_menu.html.twig" as actions_menu %} - {% set is_node = article.hasChildren or can_create %} -
  • -
    - {% if article.hasChildren %} - - {% endif %} - - - {% if article.illustration %} - {{ render_illustration(article.illustration, 20) }} - {% endif %} - - {{ article.title }} - - {% if can_create %} - - {% endif %} - {% if show_actions %} - {{ actions_menu.render_actions_menu_lazy() }} - {% endif %} -
    - {% if is_node %} -
      - {% for child in article.getChildren %} - {{ macros.render_article(child, can_create, show_actions) }} - {% endfor %} -
    - {% endif %} -
  • -{% endmacro %} - -{% import _self as macros %} +{% import "pages/tools/kb/_article_row.html.twig" as macros %} +{% import "pages/tools/kb/_actions_menu.html.twig" as actions_menu %} {% set hide_favorites = not has_other_favorites and not current_is_favorite %} +{% if show_actions %} + {{ actions_menu.render_actions_menu_placeholder() }} +{% endif %} + {# Apply the persisted collapsed state before first paint to avoid a flash of the open aside. #}