Docs›Avançado›TreeView e DB Blocks
Avançado

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

PropTipoDescrição
namestringIdentificador — usado em ops parciais, localStorage e DOM. Obrigatório.
modelstringClasse Eloquent (auto-query).
databasestringConexão (default MAIN_DATABASE).
displaystringCampo ou template {nome} ({codigo}).
keystringCampo PK (default id).
parent-fieldstringFK de auto-referência (default parent_id).
iconstringÍcone Lucide padrão (default file).
active-iconstringÍcone quando o nó está selecionado.
expanded-iconstringÍcone quando o nó está expandido com filhos.
icon-fieldstringCampo do model com ícone por nó.
order-bystringCampo de ordenação (default = display).
:filtersarrayFiltros — array de tuplas ['campo','op','val']. Único mecanismo de filtro (ver abaixo).
:itemsarrayDados manuais — dispensa model.
count-fieldstringCampo do model com badge de contagem.
:activestringID do nó ativo/selecionado.
expandedstringall (default) · none · first.
persistboolPersiste expand/collapse em localStorage.
mad:clickstringMétodo PHP ao clicar no nó — recebe o id.
classstringClasses extras no wrapper da árvore.
<mad-tree-actions position="top|bottom">sub-tagBloco de botões acima/abaixo da árvore (conteúdo Blade livre).
<mad-tree-context-menu>sub-tagMenu 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);
Persistência de estado

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.

Trait obrigatório

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);
    }
}
record-id é auto-resolvido

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

PropTipoDescrição
namestringIdentificador — usado no id da lista (#mad-db-blocks-list-{name}) e nas ops.
modestringpivot (default, vinculado a um parent) · flat (lista master independente).
pivot-modelstringClasse Eloquent das linhas.
databasestringConexão (default MAIN_DATABASE).
foreign-keystringFK pro registro pai. Obrigatório no modo pivot.
:record-idintID do registro pai. Auto-resolvido de $registroId se omitido.
row-viewstringView Blade do partial de linha (contrato: $item, $state, $name).
order-by / order-dirstringOrdenação das linhas (default id asc).
add-modestringpopover (default) · modal · drawer.
add-label / add-icon / add-variantstringBotão de adicionar.
layoutstringinline (chips, quebra linha) · stack (linhas 100% largura).
empty-textstringTexto quando a lista está vazia.
hide-add-buttonboolEsconde o botão de adicionar (lista read-only de fora).
:filtersarrayFiltros extra além do foreign-key — [['campo','op','val'], ...].
on-add / on-remove / on-updatestringNome do método-hook no host (ver convenção de retorno abaixo).
on-after-add / on-after-removestringHook pós-commit — recebe ($pivot, MadResponse $resp), ideal pra manageRow em outra grid.

Modes — pivot vs flat

mode="pivot" (default)
Coleção vinculada a um parent. foreign-key obrigatório. Hooks recebem a linha pivot.
mode="flat"
Lista master independente (tags, categorias do sistema). Sem parent — hooks recebem o record master direto.

Hooks — convenção de retorno

Retorno do hookEfeitoDescrição
void / null / truecontinuaPersiste a linha + re-renderiza a lista.
falseabortaRollback da transação + toast de erro.
MadResponsecontrole totalRetorna 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.

Próximos passos