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
Original file line number Diff line number Diff line change
Expand Up @@ -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']
);
2 changes: 1 addition & 1 deletion install/mysql/glpi-empty.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
238 changes: 158 additions & 80 deletions js/modules/Knowbase/AsideController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Promise<string>>}
*/
#children_cache = new Map();

/**
* Whether the favorites section was hidden on initial server render.
* Used to restore the correct state after clearing the search.
Expand Down Expand Up @@ -138,21 +146,82 @@ export class GlpiKnowbaseAsideController
*
* @param {HTMLElement} node
* @param {boolean} collapsed
* @returns {Promise<void>}
*/
#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()
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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)}`
+ `&current_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;
}

/**
Expand All @@ -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');
}
Expand Down Expand Up @@ -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<number>} 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<number>} 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
Expand All @@ -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.
Expand All @@ -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).
Expand Down
Loading
Loading