knowledge-base/records/plugin-dev/KB-PLUGIN-012-helpdesk-tiles-extension-patterns.md

10 KiB

id title domain tags status severity created_at updated_at applies_to
KB-PLUGIN-012 Estendendo o sistema de Tiles do Helpdesk no GLPI 11 — padrões e armadilhas plugin-dev
glpi
glpi11
helpdesk
tiles
dom-manipulation
mutation-observer
twig
singleton
plugin
active high 2026-05-09 2026-05-09
GLPI 11.0.x

Estendendo o sistema de Tiles do Helpdesk no GLPI 11

Documento consolidado das armadilhas encontradas ao implementar o plugin tilesections (categorização visual de tiles na "Página Inicial do Helpdesk"). O sistema de tiles do GLPI 11 é nativamente plano (sem agrupamento) e não foi desenhado para extensão por plugins — qualquer modificação visual precisa contornar isso.

1. Estrutura DOM dos tiles no admin (Entidade > Página Inicial do Helpdesk)

Cada tile na grade de configuração é renderizado pela template templates/pages/admin/helpdesk_home_config_tiles.html.twig em dois níveis aninhados:

<section
    class="col-12 col-lg-6 col-xl-4 d-flex-soft pointer-events-none"
    data-glpi-draggable-item
    data-glpi-helpdesk-config-tile-container         wrapper externo
    data-glpi-helpdesk-config-action-show-edit-form
    data-bs-toggle="offcanvas"
>
    <div
        data-glpi-helpdesk-config-tile
        data-glpi-helpdesk-config-tile-id="123"      ID está aqui
        data-glpi-helpdesk-config-tile-itemtype="..."
        class="card rounded my-2 cursor-pointer"
    >
        ...
    </div>
</section>

Armadilha

Mover apenas o <div> interno (que tem o data-glpi-helpdesk-config-tile-id) destrói o layout:

  • Perde as classes de coluna (col-12 col-lg-6 col-xl-4) → tile vira full-width
  • Perde os atributos de drag (data-glpi-draggable-item) → não é mais reordenável
  • Perde o trigger do offcanvas (data-bs-toggle="offcanvas") → click pra editar não funciona

Padrão correto

Selecionar o outer container com [data-glpi-helpdesk-config-tile-container] e ler os IDs do <div> interno:

const tileOuters = container.querySelectorAll('[data-glpi-helpdesk-config-tile-container]');
tileOuters.forEach(outer => {
    const inner = outer.querySelector('[data-glpi-helpdesk-config-tile-id]');
    const itemtype = inner.getAttribute('data-glpi-helpdesk-config-tile-itemtype');
    const tileId   = inner.getAttribute('data-glpi-helpdesk-config-tile-id');
    // mover `outer` (não `inner`)
});

2. TilesManager é singleton — __construct é privado

// ❌ ERRADO — Call to private __construct from scope ...
$manager = new \Glpi\Helpdesk\Tile\TilesManager();

// ✅ CORRETO
$manager = \Glpi\Helpdesk\Tile\TilesManager::getInstance();

3. Como o GLPI obtém os tiles que serão renderizados no portal público

O Glpi\Controller\Helpdesk\IndexController (que renderiza o portal /Helpdesk) chama:

$manager->getVisibleTilesForSession(Session::getCurrentSessionInfo())

Esse método:

  1. Pega os tiles do profile atual; se vazio, sobe pelo entity tree
  2. Filtra por $tile->isAvailable($session_info) (permissões, configurações, etc.)
  3. Retorna array<TileInterface&CommonDBTM> na ordem que serão renderizados

Use o mesmo método em endpoints AJAX do seu plugin para garantir alinhamento perfeito com o que o usuário vê — inclusive quando o admin está personificando outro perfil.

4. Tiles do portal público NÃO têm data-attribute de identificação

O template templates/pages/helpdesk/index.html.twig renderiza apenas:

<a class="card mx-1 my-2 flex-grow-1" href="{{ tile.getTileUrl() }}">

Não há data-tile-id, data-itemtype, etc. Não dá pra fazer matching reverso por ID.

Armadilha: matching por URL é frágil

Usar tile.getTileUrl() como chave de matching falha em casos comuns:

  • URL vazia: ExternalPageTile retorna "" se o campo url estiver vazio. Múltiplos tiles com URL vazia colidem na mesma chave (href::) e o último processado "ganha", varrendo os outros.
  • URLs duplicadas: dois ExternalPageTile apontando pra mesma URL têm chave idêntica.
  • Encoding: query strings com [] (criteria[0][field]=...) podem ser escapadas diferentemente entre o servidor e o atributo HTML.

Padrão correto: matching por índice de renderização

// servidor — endpoint AJAX
$rendered = TilesManager::getInstance()
    ->getVisibleTilesForSession(Session::getCurrentSessionInfo());

$tile_order = [];
foreach ($rendered as $tile) {
    $tile_order[] = [
        'itemtype'    => get_class($tile),
        'items_id'    => (int) $tile->getDatabaseId(),
        'sections_id' => (int) ($mappings[get_class($tile) . '::' . $tile->getDatabaseId()] ?? 0),
    ];
}
// cliente — JS do portal
const tileWrappers = tilesRow.querySelectorAll(':scope > div');
tileWrappers.forEach((wrapper, idx) => {
    const tileInfo = tileOrder[idx];   // 1:1 garantido pela ordem
    if (!tileInfo) return;
    const sectionId = tileInfo.sections_id;
    // ...
});

Funciona porque a sessão da chamada AJAX é a mesma sessão que renderizou a página — o getVisibleTilesForSession retorna a lista exata na ordem exata.

5. GLPI re-renderiza tiles via AJAX após delete/reorder

Quando o usuário deleta ou reordena um tile na tela de admin, o GLPI faz um AJAX e substitui o conteúdo do container [data-glpi-helpdesk-config-tiles] com a lista atualizada do servidor. Isso varre qualquer reorganização visual aplicada pelo plugin.

Padrão correto: MutationObserver com guarda de re-entrada

let isReorganizing = false;

function watchGridRefreshes(tilesContainer) {
    new MutationObserver(() => {
        if (isReorganizing) return;

        const hasTiles    = tilesContainer.querySelectorAll('[data-glpi-helpdesk-config-tile-container]').length > 0;
        const hasSections = tilesContainer.querySelector('.minha-section-wrapper');
        if (!hasTiles || hasSections) return;

        // Container voltou ao estado plano — re-aplicar reorganização
        queueMicrotask(async () => {
            await loadServerData();
            reorganize(tilesContainer);   // setar isReorganizing dentro
        });
    }).observe(tilesContainer, { childList: true });
}

function reorganize(tilesContainer) {
    isReorganizing = true;
    // ... mutations no DOM ...
    queueMicrotask(() => { isReorganizing = false; });
}

A guarda isReorganizing é essencial — sem ela, as próprias mutações do plugin disparam o observer em loop infinito.

6. Race condition no save de tile — não use polling

Quando o usuário salva um novo tile no offcanvas, o GLPI cria via AJAX e adiciona ao DOM assíncronamente. Polling com setInterval é frágil:

  • "Último tile no DOM" não garante ser o recém-criado (ordenação muda)
  • Timeout duro (ex: 6s) pode estourar em conexões lentas

Padrão correto: snapshot + MutationObserver com diff

function handleAddSave(tilesContainer, sectionId) {
    // Snapshot: tile keys antes do save
    const before = new Set();
    tilesContainer.querySelectorAll('[data-glpi-helpdesk-config-tile-id]').forEach(el => {
        const itemtype = el.getAttribute('data-glpi-helpdesk-config-tile-itemtype');
        const id       = el.getAttribute('data-glpi-helpdesk-config-tile-id');
        before.add(`${itemtype}::${id}`);
    });

    let resolved = false;
    const observer = new MutationObserver(() => {
        if (resolved) return;
        const tiles = tilesContainer.querySelectorAll('[data-glpi-helpdesk-config-tile-id]');
        for (const el of tiles) {
            const itemtype = el.getAttribute('data-glpi-helpdesk-config-tile-itemtype');
            const id       = el.getAttribute('data-glpi-helpdesk-config-tile-id');
            const key = `${itemtype}::${id}`;
            if (!before.has(key)) {
                resolved = true;
                observer.disconnect();
                saveSectionMapping(itemtype, id, sectionId);
                return;
            }
        }
    });
    observer.observe(tilesContainer, { childList: true, subtree: true });

    setTimeout(() => { if (!resolved) observer.disconnect(); }, 15000);
}

7. Anti-flash: esconder container antes da reorganização

Se a reorganização visual roda dentro de um setTimeout (debounce), o usuário vê os tiles em estado "plano" antes de aparecerem categorizados. Solução: adicionar a classe de loading imediatamente ao encontrar o container, não dentro do callback do timeout:

function tryInit() {
    const tilesContainer = document.querySelector('[data-glpi-helpdesk-config-tiles]');
    if (!tilesContainer) return;

    tilesContainer.classList.add('ts-loading');   // ← imediato

    setTimeout(() => {
        if (initialized) return;
        initialized = true;
        init(tilesContainer);   // remove 'ts-loading' no final
    }, 300);
}

Com CSS:

[data-glpi-helpdesk-config-tiles].ts-loading {
    opacity: 0;
    transition: opacity 0.15s ease-in-out;
}

8. Templates de plugin têm namespace, não override direto

GLPI 11 carrega templates de plugin em namespace dedicado:

// Glpi\Application\View\TemplateRenderer
$loader->addPath(Plugin::getPhpDir($plugin_key . '/templates'), $plugin_key);

Isso registra @plugin_key/path/to/template.html.twig. Não substitui o template core no mesmo path.

Para "estender" templates core, opções são:

  1. Hooks: usar Hooks::ADD_JAVASCRIPT / Hooks::ADD_CSS para injetar comportamento via JS/CSS
  2. API server-side: chamar APIs nativas do GLPI (TilesManager::getInstance()) em endpoints AJAX próprios
  3. DOM manipulation: aceitar a fragilidade e robustecer com observers (este KB)

Override direto de template core via plugin não é suportado oficialmente.

Referências do core GLPI 11

Caminho Conteúdo
src/Glpi/Helpdesk/Tile/TilesManager.php Singleton com getInstance() e getVisibleTilesForSession()
src/Glpi/Helpdesk/Tile/{FormTile,GlpiPageTile,ExternalPageTile}.php Implementações de tile
src/Glpi/Controller/Helpdesk/IndexController.php Renderização do portal público
templates/pages/admin/helpdesk_home_config_tiles.html.twig Template do admin grid
templates/pages/helpdesk/index.html.twig Template do portal público
src/Glpi/Application/View/TemplateRenderer.php Mecanismo de namespace de templates de plugin