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 |
|
active | high | 2026-05-09 | 2026-05-09 |
|
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:
- Pega os tiles do profile atual; se vazio, sobe pelo entity tree
- Filtra por
$tile->isAvailable($session_info)(permissões, configurações, etc.) - 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:
ExternalPageTileretorna""se o campourlestiver vazio. Múltiplos tiles com URL vazia colidem na mesma chave (href::) e o último processado "ganha", varrendo os outros. - URLs duplicadas: dois
ExternalPageTileapontando 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:
- Hooks: usar
Hooks::ADD_JAVASCRIPT/Hooks::ADD_CSSpara injetar comportamento via JS/CSS - API server-side: chamar APIs nativas do GLPI (
TilesManager::getInstance()) em endpoints AJAX próprios - 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 |