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

276 lines
10 KiB
Markdown

---
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
<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:
```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<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:
```html
<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
```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 |