TreeView e DB Blocks
Padrões de árvore hierárquica e listas pivot reativas.
Componentes para estruturas hierárquicas e CRUD inline de coleções vinculadas:
<mad-tree-view> (árvore com expand/collapse, auto-query Eloquent)
e <mad-db-blocks> (lista-pivot com add/remove/update inline,
sem reload). Pra referência rápida de props de mad-tree-view, veja
também a
página de componente dedicada —
esta página aprofunda padrões de uso e combina os dois componentes.
MadTreeView
Auto-query do banco
<mad-tree-view name="pastas" model="GedFolder" database="communication"
display="name" parent-field="parent_id"
icon="folder" active-icon="folder-open"
order-by="name" :active="$folderId"
count-field="_document_count"
mad:click="onSelectFolder" persist>
<mad-tree-context-menu>
<mad-menu-item icon="folder-plus" navigate="GedFolderForm" params="{parent_id: {id}}">
Nova subpasta
</mad-menu-item>
<mad-menu-separator />
<mad-menu-item icon="pencil" navigate="GedFolderForm::onEdit({id})">Editar</mad-menu-item>
<mad-menu-item icon="trash-2" variant="danger" mad:click="onDeleteFolder({id})">
Excluir
</mad-menu-item>
</mad-tree-context-menu>
</mad-tree-view>
Props
| Prop | Tipo | Descrição |
|---|---|---|
name | string | Identificador — usado em ops parciais, localStorage e DOM. Obrigatório. |
model | string | Classe Eloquent (auto-query). |
database | string | Conexão (default MAIN_DATABASE). |
display | string | Campo ou template {nome} ({codigo}). |
key | string | Campo PK (default id). |
parent-field | string | FK de auto-referência (default parent_id). |
icon | string | Ícone Lucide padrão (default file). |
active-icon | string | Ícone quando o nó está selecionado. |
expanded-icon | string | Ícone quando o nó está expandido com filhos. |
icon-field | string | Campo do model com ícone por nó. |
order-by | string | Campo de ordenação (default = display). |
:filters | array | Filtros — array de tuplas ['campo','op','val']. Único mecanismo de filtro (ver abaixo). |
:items | array | Dados manuais — dispensa model. |
count-field | string | Campo do model com badge de contagem. |
:active | string | ID do nó ativo/selecionado. |
expanded | string | all (default) · none · first. |
persist | bool | Persiste expand/collapse em localStorage. |
mad:click | string | Método PHP ao clicar no nó — recebe o id. |
class | string | Classes extras no wrapper da árvore. |
| <mad-tree-actions position="top|bottom"> | sub-tag | Bloco de botões acima/abaixo da árvore (conteúdo Blade livre). |
| <mad-tree-context-menu> | sub-tag | Menu de contexto do nó — aceita <mad-menu-item> e <mad-menu-separator>. |
Filtros — :filters (array-DSL)
A prop :filters é o único mecanismo de filtro do
<mad-tree-view> — um array de tuplas [campo, operador,
valor] aplicado direto no Eloquent Builder do model. Não
existe prop :criteria; qualquer valor passado nela é
silenciosamente ignorado pelo compilador.
{{-- Filtro simples (1 condição) --}}
<mad-tree-view name="categorias" model="Categoria" display="nome"
:filters="[['ativo', '=', '1']]" />
{{-- Múltiplas condições (AND implícito) --}}
<mad-tree-view name="categorias" model="Categoria" display="nome"
:filters="[['ativo', '=', '1'], ['tipo', '=', 'produto']]" />
{{-- Operadores: '=', '>', '<', '>=', '<=', '<>', 'in', 'not in', 'is null', 'is not null' --}}
<mad-tree-view name="pastas" model="GedFolder" display="name"
:filters="[['owner_id', 'in', $ownerIds]]" />
{{-- Filtro dinâmico vindo de outra parte da tela --}}
<mad-tree-view name="categorias" model="Categoria" display="nome"
:filters="[['empresa_id', '=', $this->empresaId]]" />
Pra uma fonte mais elaborada que :filters não cobre (joins,
subqueries, scopes do model), monte o array de ids fora do componente
reaproveitando um Eloquent Builder normal — sem nenhuma classe de critério
dedicada:
@php
$ativos = Categoria::ativas()->pluck('id')->all(); // scope do model, por exemplo
@endphp
<mad-tree-view name="categorias" model="Categoria" display="nome"
:filters="[['id', 'in', $ativos]]" />
Dados manuais
@php
$tree = [
['id' => 1, 'name' => 'Raiz A', 'icon' => 'folder', '_count' => 5, 'children' => [
['id' => 2, 'name' => 'Sub A1', 'icon' => 'folder', '_count' => 3, 'children' => []],
]],
['id' => 3, 'name' => 'Raiz B', 'icon' => 'file', '_count' => 0, 'children' => []],
];
@endphp
<mad-tree-view name="categorias" :items="$tree" display="name"
icon="folder" count-field="_count" mad:click="onSelect" persist />
Ops parciais — MadResponse
// Adicionar nó
return (new MadResponse())
->treeAddNode('pastas', [
'id' => $folder->id,
'parent_id' => $folder->parent_id,
'name' => $folder->name,
'icon' => 'folder',
])
->toast('Pasta criada!', 'success')
->closeDrawer();
// Remover nó (com animação)
return (new MadResponse())
->treeRemoveNode('pastas', $folderId)
->toast('Pasta excluída', 'success');
// Atualizar nó (label, ícone, badge)
return (new MadResponse())
->treeUpdateNode('pastas', $folderId, [
'name' => $novoNome,
'icon' => 'folder-lock',
'count' => 42,
]);
// Mudar nó ativo
return (new MadResponse())->treeSetActive('pastas', $folderId);
Com persist, o estado de expand/collapse é salvo em
localStorage com a key mad-tree-{name}
(array de ids expandidos, serializado em JSON) — restaura sozinho ao
recarregar a página. Duas árvores com o mesmo name na
mesma origem compartilham o estado.
MadDbBlocks
Componente genérico de lista-pivot com CRUD inline: chips,
shares, links, attachments — qualquer coleção 1:N vinculada a um registro pai
(ou uma lista master independente, no modo flat). O visual de
cada linha é delegado a um partial Blade fornecido pelo consumidor.
O controller host precisa de use \Mad\Form\MadDbBlocksTrait;.
Os métodos blockAdd, blockRemove e
blockUpdate são roteados contra esse host — sem o trait, a
action falha com "Método não permitido".
Compartilhamento de documento (lista de users + role)
@php
$roles = ['manage' => 'Gerenciar', 'edit' => 'Editar', 'view' => 'Visualizar'];
@endphp
<mad-db-blocks name="shares"
pivot-model="GedDocumentShare"
database="communication"
foreign-key="document_id"
row-view="ged.share-row"
order-by="created_at"
add-mode="modal"
add-label="+ Compartilhar"
empty-text="Nenhum compartilhamento"
on-add="onShareAdd">
<mad-db-blocks-form>
<mad-dbunique-search-field name="principal_id" label="Usuário"
model="SystemUsers" display="name" required />
<mad-select-field name="role" label="Permissão" :items="$roles" required />
</mad-db-blocks-form>
</mad-db-blocks>
{{-- ged/share-row.blade.php (partial — contrato: $item, $state, $name) --}}
@php
$roles = ['manage' => 'Gerenciar', 'edit' => 'Editar', 'view' => 'Visualizar'];
@endphp
<div class="mad-db-blocks-row" id="block-row-{{ $name }}-{{ $item->id }}">
<mad-avatar :name="$item->principal->name" size="sm" />
<div style="flex:1;">
<div>{{ $item->principal->name }}</div>
<div class="muted">{{ $item->origem }}</div>
</div>
<select mad:change="onRoleChange({{ $item->id }},'role','{{ $state }}')">
@foreach($roles as $v => $l)
<option value="{{ $v }}" @selected($item->role === $v)>{{ $l }}</option>
@endforeach
</select>
<button mad:click="blockRemove({{ $item->id }},'{{ $state }}')">×</button>
</div>
mad:change sempre anexa o valor do elemento por ÚLTIMO
mad-livewire.js faz params.push(el.value) — o valor
do <select> chega sempre como o último
argumento posicional, nunca no meio da lista. Como
blockUpdate(int $id, string $field, mixed $value, string $state)
espera $value na 3ª posição e $state na 4ª, não dá
pra chamá-lo direto num mad:change — por isso o host expõe um
método-ponte (onRoleChange, abaixo) que recebe os argumentos na
ordem que o mad:change realmente envia
(id, field, state, novoValor) e repassa pro trait na ordem certa.
Passar algo como this.value literal dentro do atributo
não funciona: o parser de argumentos do MadWire roda
JSON.parse() no conteúdo entre parênteses, não avalia JS.
use Mad\Component\MadComponent;
use Mad\Form\MadDbBlocksTrait;
use Mad\Http\MadResponse;
class GedDocumentDetail extends MadComponent
{
use MadDbBlocksTrait;
public ?int $registroId = null;
public function onShareAdd(GedDocumentShare $share): void
{
$share->created_by = session('userid');
$share->created_at = now();
}
// Ponte pro trait: mad:change entrega (id, field, state, valor) — nessa
// ordem, porque "valor" vem sempre por ultimo (auto-append do JS) — e
// repassa pro blockUpdate() do trait na ordem que ELE espera.
public function onRoleChange(int $id, string $field, string $state, string $value): MadResponse
{
return $this->blockUpdate($id, $field, $value, $state);
}
}
No modo pivot (default), se você não passar
:record-id explicitamente, o componente lê
$comp->registroId do MadComponent atual via
MadRenderContext — funciona out of the box em qualquer
tela de detalhe/edição que já segue a convenção registroId.
Props
| Prop | Tipo | Descrição |
|---|---|---|
name | string | Identificador — usado no id da lista (#mad-db-blocks-list-{name}) e nas ops. |
mode | string | pivot (default, vinculado a um parent) · flat (lista master independente). |
pivot-model | string | Classe Eloquent das linhas. |
database | string | Conexão (default MAIN_DATABASE). |
foreign-key | string | FK pro registro pai. Obrigatório no modo pivot. |
:record-id | int | ID do registro pai. Auto-resolvido de $registroId se omitido. |
row-view | string | View Blade do partial de linha (contrato: $item, $state, $name). |
order-by / order-dir | string | Ordenação das linhas (default id asc). |
add-mode | string | popover (default) · modal · drawer. |
add-label / add-icon / add-variant | string | Botão de adicionar. |
layout | string | inline (chips, quebra linha) · stack (linhas 100% largura). |
empty-text | string | Texto quando a lista está vazia. |
hide-add-button | bool | Esconde o botão de adicionar (lista read-only de fora). |
:filters | array | Filtros extra além do foreign-key — [['campo','op','val'], ...]. |
on-add / on-remove / on-update | string | Nome do método-hook no host (ver convenção de retorno abaixo). |
on-after-add / on-after-remove | string | Hook pós-commit — recebe ($pivot, MadResponse $resp), ideal pra manageRow em outra grid. |
Modes — pivot vs flat
mode="pivot" (default)foreign-key obrigatório. Hooks recebem a linha pivot.mode="flat"Hooks — convenção de retorno
| Retorno do hook | Efeito | Descrição |
|---|---|---|
void / null / true | continua | Persiste a linha + re-renderiza a lista. |
false | aborta | Rollback da transação + toast de erro. |
MadResponse | controle total | Retorna direto — permite save multi-tabela, redirect, ops extras. |
blockAdd/blockRemove/blockUpdate rodam
dentro de DB::connection($db)->transaction(...) — retornar
false de um hook lança uma exceção de cancelamento interna
(MadDbBlockCancelled), que força rollback automático antes de
virar um toast de erro pro usuário.
Tags coloridas com chips (caso especial)
<mad-db-blocks name="tags"
pivot-model="GedDocumentTag" database="communication"
foreign-key="document_id" :record-id="$docId"
row-view="components.partials.tag-chip"
add-mode="popover" add-label="+ Tag"
on-add="onTagCreate">
<mad-db-blocks-form>
<mad-input-field name="name" label="Nome" required />
<mad-color-field name="color" label="Cor" />
</mad-db-blocks-form>
</mad-db-blocks>
public function onTagCreate(GedDocumentTag $pivot): void
{
$data = request('mad_model', []);
// Cria o master GedTag
$tag = GedTag::create([
'name' => trim($data['name'] ?? ''),
'color' => $data['color'] ?? '#a78bfa',
'created_by' => session('userid'),
]);
// Vincula via pivot
$pivot->tag_id = $tag->id;
}
Combinação — sidebar com tree + main com db-blocks
<div style="display:grid;grid-template-columns:280px 1fr;gap:24px;">
<aside>
<mad-tree-view name="pastas" model="GedFolder" parent-field="parent_id"
display="name" icon="folder" mad:click="onSelectFolder" />
</aside>
<main>
<h2>Documentos</h2>
<mad-grid self>
{{-- listagem de documentos da pasta selecionada --}}
</mad-grid>
@if($docId)
<mad-db-blocks name="tags" pivot-model="GedDocumentTag"
foreign-key="document_id" :record-id="$docId"
row-view="components.partials.tag-chip"
on-add="onTagCreate">
<mad-db-blocks-form>
<mad-input-field name="name" required />
<mad-color-field name="color" />
</mad-db-blocks-form>
</mad-db-blocks>
@endif
</main>
</div>
NUNCA fazer
// ERRADO: usar mad-db-blocks sem o trait — "Método não permitido: blockAdd"
class MeuForm extends MadComponent { /* sem trait */ }
// CERTO
class MeuForm extends MadComponent {
use \Mad\Form\MadDbBlocksTrait;
}
{{-- ERRADO: row-view sem id no elemento raiz — blockUpdate não consegue fazer patch --}}
<div class="row">{{ $item->nome }}</div>
{{-- CERTO --}}
<div class="row" id="block-row-{{ $name }}-{{ $item->id }}">{{ $item->nome }}</div>
{{-- ERRADO: montar árvore manual com @foreach recursivo --}}
@foreach($folders as $f)
<div style="padding-left:{{ $depth * 14 }}px;">{{ $f->name }}</div>
@endforeach
{{-- CERTO --}}
<mad-tree-view name="pastas" model="GedFolder" parent-field="parent_id" display="name" />
{{-- ERRADO: passar critério de query pronto via :criteria — não existe --}}
@php $crit = Categoria::where('ativo', '=', '1'); @endphp
<mad-tree-view name="categorias" :criteria="$crit" />
{{-- CERTO: model + :filters (array-DSL) --}}
<mad-tree-view name="categorias" model="Categoria" display="nome" parent-field="parent_id"
:filters="[['ativo', '=', '1']]" />
// ERRADO: recarregar a árvore/lista inteira após criar/editar/excluir
$this->loadData(); // full re-render
// CERTO: ops parciais
return (new MadResponse())->treeAddNode('pastas', [...]);
return (new MadResponse())->treeRemoveNode('pastas', $id);
return (new MadResponse())->treeUpdateNode('pastas', $id, ['name' => $nome]);
// db-blocks já faz isso sozinho — blockAdd/blockRemove/blockUpdate só
// re-renderizam #mad-db-blocks-list-{name}, nunca a tela inteira.