Docs›Avançado›MadDataGrid avançado
Avançado

MadDataGrid avançado

Custom queries, agrupamento, exports, card-view, edição inline.

Esta página cobre features avançadas do MadDataGrid além do CRUD básico: agrupamento, edição inline, row-attach (quick-edit na linha), query custom, exportação, card view, sticky header, ações condicionais, <mad-data-table> com :query e padrões de filtro.

Agrupamento

Single field

class VendasListagem extends MadDataGrid
{
    protected string $groupBy    = 'ano';
    protected string $groupMask  = 'Ano {ano}';
    protected bool   $groupTotal = true;  // subtotais
}

Multi-nível

protected string|array $groupBy    = ['ano', 'mes'];
protected string|array $groupMask  = ['{ano}', 'Mes {mes}'];
protected bool         $groupTotal = true;

Também dá pra declarar direto no Blade: <mad-grid self group-by="ano" :group-mask="'{ano}'" group-total>. Agrupamentos aparecem automaticamente na exportação PDF/XLSX/CSV (mesma quebra + subtotais).

Edição inline

{{-- Texto sempre editável (sem clique) --}}
<mad-col field="obs" label="Obs" edit edit-type="text" edit-mode="inline" />

{{-- Monetário com clique-para-editar --}}
<mad-col field="valor" label="Valor" money="R$"
    edit edit-type="money" edit-decimals="2" edit-prefix="R$" edit-mode="click" />

{{-- Select inline --}}
<mad-col field="status" label="Status"
    edit edit-type="select" :edit-opts="['A' => 'Ativo', 'I' => 'Inativo', 'P' => 'Pendente']" />

{{-- Dbcombo inline (busca em outro model) --}}
<mad-col field="categoria_id" label="Categoria"
    edit edit-type="dbcombo" edit-model="Categoria" edit-display="nome" />
AtributoTipoDescrição
editboolHabilita edição inline na coluna.
edit-typestringtext, number, numeric, money, select, date, textarea, dbcombo.
edit-modestringdblclick (default) · click · inline (sempre editável).
edit-decimalsintCasas decimais (money/number/numeric).
edit-prefix / edit-suffixstringPrefixo/sufixo (ex: R$, kg).
:edit-optsarrayMapa ['valor' => 'label'] (select).
edit-model / edit-displaystringModel + campo de exibição (dbcombo).
edit-min / edit-max / edit-stepfloatLimites do input numérico.

O salvamento dispara onInlineSave(int $id, string $field, mixed $value), que grava direto via $model::find($id)->save() e atualiza só a linha editada (manageRow) — sem full re-render da listagem.

Row-attach — quick-edit anexado à linha

Em vez de abrir o form na cortina lateral (drawer), o atributo booleano row do <mad-nav> abre o MESMO form control anexado à linha clicada — uma <tr> extra logo abaixo, com colspan total (estilo "quick edit" do GitHub/Linear). Nenhuma mudança é necessária na classe do form, nem no onSave: quem decide a apresentação é o chamador, não o componente.

<mad-grid model="User" per-page="20">
    <mad-actions>
        {{-- edição rápida anexada à linha --}}
        <mad-nav icon="pencil" :label="__('mad.edit')"
                 target="UserForm::onEdit({id})" row />
    </mad-actions>
</mad-grid>

Equivalentes no builder fluent (PHP):

$grid->nav('pencil', 'Editar', UserForm::class, 'onEdit', row: true);
$grid->act('edit', 'pencil', 'Editar')->attachRow(UserForm::class, 'onEdit');
GridAction::make('edit')->nav(UserForm::class, 'onEdit')->row();

View compacta com isRowAttach()

MadComponent::isRowAttach(): bool permite adaptar a view do form ao contexto — esconder abas, checklists e detail-forms quando o form roda dentro da linha. Os campos escondidos continuam no state e são re-gravados intactos no save.

<mad-form submit="onSave">
    <mad-form-section :title="__('admin.user_data')" icon="user">
        {{-- campos principais, sempre visíveis --}}
    </mad-form-section>

    @if(!$that->isRowAttach())
        {{-- pesado/secundário: só na cortina/página --}}
        <mad-tabs> ... </mad-tabs>
    @endif

    <mad-btn variant="primary" type="submit" icon="save">Salvar</mad-btn>
</mad-form>

Mecânica

EtapaO que acontece
CompileMadGridCompiler vê row → emite 'navRow' => true no actConfig; MadDataGrid hidrata em GridAction::row().
EmissãoGridAction::getNavAttr() (precedência row > drawer > auto) → onclick="Mad.rowAttach(this, 'UserForm@onEdit', {id:2}, '/app/usuarios/2/editar')".
ClientMad.rowAttach() (mad.js): toggle na mesma linha, single-open no tbody, GET com headers X-Mad-Partial: 1 + X-Mad-Row-Attach: 1, insere <tr class="mad-dg-attach-row">. Sem <tr> (card-view) → fallback Mad.get (drawer normal).
ServerO header X-Mad-Row-Attach faz _wrapRenderedHtml() devolver o componente cru, sem o chrome de drawer/modal. Permissões: caminho idêntico ao drawer.
PersistênciaOs POSTs do MadWire não reenviam o header — a flag _rowAttach é persistida no mad_state, então isRowAttach() continua verdadeiro em todo re-render (ex.: erro de validação).
SavecloseDrawer() é interceptado no mad-livewire.js: dentro de tr.mad-dg-attach-row remove a <tr>; fora dela dispara o evento de drawer normal. Por isso o onSave é o mesmo nos dois contextos.
// onSave INTOCADO — serve drawer e row-attach
return (new MadResponse())
    ->toast(__('admin.user_saved'), 'success')
    ->closeDrawer()                            // fecha a attach-row (interceptado)
    ->manageRow($user->id, UserList::class);   // atualiza a linha
Limitações (v1)

Sort, filtro, busca e paginação fazem full re-render da grid e descartam a attach-row (inclusive edições não salvas, sem confirm). Não há animação de saída. Em grids que rodam primariamente em card-view, evite row — o fallback funciona, mas o UX vira drawer de qualquer jeito.

Query customizada

Sobrescreva query(): array só quando o auto-query (Eloquent builder nativo) não atende — joins muito complexos, agregação pesada ou performance crítica. Sem argumentos: monte e execute a query você mesmo, retornando ['items' => [...], 'total' => int]. Retornar array vazio faz o grid cair de volta no auto-query.

use Illuminate\Support\Facades\DB;

class VendasComJoinListagem extends MadDataGrid
{
    protected string $database = 'business';

    protected function query(): array
    {
        $offset = ($this->page - 1) * $this->perPage;

        $items = DB::connection($this->database)->select("
            SELECT p.id, p.numero, c.nome AS cliente_nome,
                   COUNT(i.id) AS qtd_itens, SUM(i.valor) AS total
            FROM pedido p
            JOIN cliente c ON c.id = p.cliente_id
            LEFT JOIN pedido_item i ON i.pedido_id = p.id
            WHERE p.ativo = ?
            GROUP BY p.id, p.numero, c.nome
            ORDER BY p.numero DESC
            LIMIT ? OFFSET ?
        ", ['1', $this->perPage, $offset]);

        $total = DB::connection($this->database)
            ->table('pedido')->where('ativo', '=', '1')->count();

        return ['items' => $items, 'total' => $total];
    }
}
Quando usar query custom

Use apenas pra joins complexos, agregações que o builder Eloquent não atende confortavelmente, ou performance crítica. Pra 95% dos casos, $this->searchQuery (closure aplicada no builder via onSearch()) basta — veja a seção de filtros mais abaixo.

Card view

<mad-grid self per-page="12" card-view>
    <mad-columns>
        <mad-col field="foto_url" label="Foto" card-role="image" hide />
        <mad-col field="nome" label="Nome" card-role="title" />
        <mad-col field="dt_pedido" label="Data" date="d/m/Y" card-role="subtitle" />
        <mad-col field="status" label="Status"
            badge="A:success:Ativo|I:danger:Inativo" card-role="badge" />
        <mad-col field="valor_total" label="Valor" money="R$" card-role="highlight" />
        <mad-col field="obs" label="Obs" />  {{-- sem role = corpo --}}
    </mad-columns>
</mad-grid>
AtributoTipoDescrição
card-viewboolHabilita toggle tabela/cards na toolbar.
card-defaultboolCards como visualização padrão (abre nesse modo).
card-colsintColunas do grid de cards (default 3).

Card roles

card-rolePosiçãoDescrição
titletopoTítulo principal. Auto: primeira coluna de texto não-id/badge/money/date.
subtitletopoTexto secundário. Auto: primeira coluna com date.
badgetopo-direitaBadge no canto. Auto: primeira coluna com badge.
highlightdestaqueValor grande (preço). Auto: primeira coluna com money.
imagetopoImagem do card. Sempre explícito.
(vazio)corpoCampo label:valor no corpo do card.

Exportação customizada

// Desabilitar (ou no Blade: <mad-grid self no-export>)
protected bool $exportable = true;

// Título customizado (header do PDF + sheet name do XLSX)
protected string $exportTitle    = 'Relatório de Pedidos';
protected string $exportFilename = 'relatorio-pedidos';

// Header PDF customizado — placeholders {TITLE}, {DATE}, {TOTAL}
protected function exportPdfHeader(): string
{
    return '
' . '
Empresa XYZ
' . '
Gerado em {DATE} | {TOTAL} registros
' . '
'; } // Footer PDF customizado protected function exportPdfFooter(): string { return 'Confidencial | {DATE}'; }

Sticky header e filtros persistidos

<mad-grid self sticky :remember-filters="true">
    {{-- Thead e quebras de grupo fixos ao rolar --}}
    {{-- Filtros e sort persistem entre navegações via sessão --}}
</mad-grid>

Ações condicionais avançadas

<mad-act method="onAprovar" icon="check-circle" label="Aprovar" primary
    display-condition="MinhaListagem::podeAprovar"
    confirm="Confirmar aprovação?" />

<mad-act method="onCancelar" icon="ban" label="Cancelar" danger
    confirm="Tem certeza?"
    when-field="status_id" when-nin="8,9,10" />

<mad-act method="onReabrir" icon="rotate-ccw" label="Reabrir"
    when-field="status_id" when-value="9" />
public static function podeAprovar(array $row): bool
{
    return in_array((string) ($row['status_id'] ?? ''), ['1', '2', '3'], true)
        && session('login') !== 'guest';
}

Transform de coluna com HTML rico

{{-- ATENÇÃO: atributo html é obrigatório quando transform retorna HTML --}}
<mad-col field="status" label="Status" transform="MinhaListagem::transformStatus" html />
public static function transformStatus(mixed $value, object $row): string
{
    $cores  = ['A' => '#3DDC97', 'I' => '#F25F5C', 'P' => '#F5A524'];
    $labels = ['A' => 'Ativo', 'I' => 'Inativo', 'P' => 'Pendente'];

    $cor   = $cores[$value]  ?? '#888';
    $label = $labels[$value] ?? $value;

    return "{$label}";
}

manageRow + removeRow — atualizar grid sem reload

// No FormController (drawer)
public function onSave(): MadResponse
{
    // ... validar e salvar ...
    return (new MadResponse())
        ->toast('Salvo!', 'success')
        ->closeDrawer()
        ->manageRow($registro->id, MinhaListagem::class);
}

// No ListController (grid)
public function onDelete(int $id): MadResponse
{
    // ... excluir ...
    return (new MadResponse())
        ->removeRow($id, static::class)
        ->toast('Excluído', 'success');
}

Drawer dinâmico — conteúdo injetado

<mad-drawer name="trace-viewer" title="Trace SQL" size="lg">
    <div id="trace-viewer-content"></div>
</mad-drawer>
public function onVerTrace(int $id): MadResponse
{
    $log = SystemSqlLog::on('log')->findOrFail($id);

    $html = '
' . htmlspecialchars($log->log_trace) . '
'; return (new MadResponse()) ->html('#trace-viewer-content', $html) // injeta ANTES ->openDrawer('trace-viewer'); // depois abre }
Ordem importa

html() antes de openDrawer() — o conteúdo precisa estar no DOM quando o drawer animar.

Search global com searchColumns

<mad-grid self searchable
    :search-columns="['nome', 'cod_barras', 'familia_produto.nome', 'categoria.descricao']">
    {{-- Busca em múltiplas colunas, incluindo relacionamentos via notação ponto --}}
</mad-grid>

Filtros customizados em onSearch

onSearch() monta uma closure em $this->searchQuery, aplicada direto no Eloquent\Builder — sem nenhuma classe de critério intermediária. O grid só chama a closure quando precisa; nunca aplique where com valor vazio (gera condição inútil e atrapalha a paginação).

use Illuminate\Database\Eloquent\Builder;

class PedidoList extends MadDataGrid
{
    public string $busca        = '';
    public string $statusFiltro = '';
    public string $dtIni        = '';
    public string $dtFim        = '';

    public function onSearch(): void
    {
        $this->searchQuery = function (Builder $q) {
            $q->whereNull('deleted_at');

            if ($this->busca !== '') {
                $v = trim($this->busca);
                is_numeric($v)
                    ? $q->where('id', '=', (int) $v)
                    : $q->where('nome', 'like', "%{$v}%");
            }

            if ($this->statusFiltro !== '') {
                $q->where('estado_id', '=', $this->statusFiltro);
            }

            if ($this->dtIni !== '') {
                $q->where('dt_pedido', '>=', $this->dtIni);
            }
            if ($this->dtFim !== '') {
                $q->where('dt_pedido', '<=', $this->dtFim);
            }
        };
    }
}

Filtros declarativos — <mad-grid-filters>

Quando a listagem tem mais de 1-2 filtros, prefira a tag declarativa <mad-grid-filters> sobre forms manuais. Renderers prontos: toolbar, chips, drawer, modal, form, sidebar. Cada filho precisa de uma prop pública correspondente no controller — a closure de filtro mora em onSearch().

<mad-grid-filters style="toolbar">
    <mad-input-field name="busca" label="Busca" placeholder="ID ou obs..." />
    <mad-dbcombo-field name="status_id" label="Status"
        model="EstadoPedido" display="nome" />
    <mad-date-field name="dtIni" label="De" />
    <mad-date-field name="dtFim" label="Até" />
</mad-grid-filters>

<mad-grid self per-page="15">...</mad-grid>
use Illuminate\Database\Eloquent\Builder;

class PedidoList extends MadDataGrid
{
    protected string $model           = Pedido::class;
    protected bool   $rememberFilters = true;
    protected string $periodType      = 'date-range';
    protected string $dateField       = 'dt_pedido';
    protected array  $skipAutoFilter  = ['busca'];

    public string $busca     = '';
    public string $status_id = '';   // nome = coluna → auto-filter

    public function onSearch(): void
    {
        $this->searchQuery = function (Builder $q) {
            if ($this->busca !== '') {
                $v = trim($this->busca);
                is_numeric($v)
                    ? $q->where('id', '=', (int) $v)
                    : $q->where('obs', 'like', "%{$v}%");
            }
        };
    }
}
Família compartilhada

A mesma engine de filtros declarativos alimenta <mad-grid-filters>, <mad-dash-filters>, <mad-kanban-filters>, <mad-calendar-filters> e <mad-gantt-filters> — aliases semânticos do mesmo compilador. Veja a doc dedicada pros 6 styles disponíveis.

<mad-data-table> com :query — tabela read-only no dashboard

<mad-data-table> é a grid read-only embutível (sem actions, sem edit, sem search, sem paginação). A fonte de dados tem precedência bem definida em GridRenderHelpers::renderDataTable(): :query (Builder pronto) > model (auto-query) > :items (array inline).

Com :query a tabela vira widget de dashboard de primeira classe: o Builder passado já traz o período e os filtros do <mad-dash-filters>, exatamente como <mad-db-chart> e <mad-metric-card>. O Builder é clonado antes de executar (clone-no-mutate), então a MESMA fonte pode alimentar vários widgets do painel.

<mad-data-table :query="$that->baseQuery('Pedido')->where('status', 'A')"
    order-by="dt_pedido desc" limit="10"
    group-by="ano" group-mask="Ano {ano}" group-total
    zebra bordered compact empty-text="Nenhum pedido no período">
    <mad-col field="numero" label="Nº" />
    <mad-col field="cliente_nome" label="Cliente" />
    <mad-col field="dt_pedido" label="Data" date="d/m/Y" />
    <mad-col field="valor_total" label="Valor" money="R$" total="sum" />
</mad-data-table>
AtributoTipoDescrição
:queryBuilder|ClosureEloquent/Query Builder pronto (ou closure que devolve um). Maior precedência.
model / databasestringAuto-query pelo model (2ª precedência).
:itemsarrayLinhas inline (3ª precedência).
:filtersarrayFiltros DSL aplicados ao Builder (QuerySource::applyArrayFilters).
order-by / limitstring / int"campo desc" e limite de linhas.
group-by / group-maskstring|arrayAgrupamento hierárquico (array = N níveis).
group-totalboolSubtotais por grupo.
no-totalsboolDesliga o footer de totais gerais.
zebraboolLinhas alternadas (default true; desligue com :zebra="false").
bordered / compactboolBordas em todas as células · padding menor.
empty-text / classstringMensagem de vazio · classes extras na tabela.
Relacionamento em field path

field="{categoria->nome}" só resolve quando a fonte devolve models Eloquent (aí é lazy-load, 1 query por linha). Se o :query for um Query Builder cru (DB::table(...)), as linhas vêm como stdClass e o caminho de relação simplesmente não existe — faça JOIN e selecione a coluna com alias (c.nome AS cliente_nome).

Performance

N+1 queries

Acessar um relacionamento numa transform de coluna (ex: $row->categoria->nome) ou num field path (field="{categoria->nome}") dispara lazy-load — 1 query por linha — se a relação não estiver eager-loaded. Isso NÃO é automático: declare as relações em protected array $with = ['categoria']; na listagem. O grid aplica $itemsQ->with($this->with) na query da página, evitando o N+1.

Per-page alto

per-page="100" traz 100 registros + colunas relacionadas. Com 20+ colunas e relacionamentos, uma response de 500KB+ é normal. Considere paginação server-side mais agressiva ou habilitar card-view com virtual scroll pra datasets grandes.

NUNCA fazer

// ERRADO: TCriteria/TFilter não existem mais — onSearch() não recebe criterio.
public function onSearch(): void
{
    $crit = new \TCriteria();
    $crit->add(new \TFilter('deleted_at', 'is', null));
    $this->searchCriteria = $crit;
}

// CERTO: closure no Eloquent Builder via $this->searchQuery.
public function onSearch(): void
{
    $this->searchQuery = function (\Illuminate\Database\Eloquent\Builder $q) {
        $q->whereNull('deleted_at');
    };
}
// ERRADO: query() com argumento TCriteria — assinatura removida.
protected function query(\TCriteria $criteria): array { ... }

// CERTO: query() sem argumentos — monte e execute você mesmo.
protected function query(): array
{
    return ['items' => $items, 'total' => $total];
}

Próximos passos