--- id: KB-PLUGIN-012 title: Estendendo o sistema de Tiles do Helpdesk no GLPI 11 — padrões e armadilhas domain: plugin-dev tags: - glpi - glpi11 - helpdesk - tiles - dom-manipulation - mutation-observer - twig - singleton - plugin status: active severity: high created_at: 2026-05-09 updated_at: 2026-05-09 applies_to: - 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**: ```html
...
``` ### Armadilha Mover apenas o `
` 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 `
` interno: ```js 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 ```php // ❌ 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: ```php $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` 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: ```html ``` 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 ```php // 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), ]; } ``` ```js // 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 ```js 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 ```js 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: ```js 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: ```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: ```php // 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 |