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" />
| Atributo | Tipo | Descrição |
|---|---|---|
edit | bool | Habilita edição inline na coluna. |
edit-type | string | text, number, numeric, money, select, date, textarea, dbcombo. |
edit-mode | string | dblclick (default) · click · inline (sempre editável). |
edit-decimals | int | Casas decimais (money/number/numeric). |
edit-prefix / edit-suffix | string | Prefixo/sufixo (ex: R$, kg). |
:edit-opts | array | Mapa ['valor' => 'label'] (select). |
edit-model / edit-display | string | Model + campo de exibição (dbcombo). |
edit-min / edit-max / edit-step | float | Limites 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
| Etapa | O que acontece |
|---|---|
| Compile | MadGridCompiler vê row → emite 'navRow' => true no actConfig; MadDataGrid hidrata em GridAction::row(). |
| Emissão | GridAction::getNavAttr() (precedência row > drawer > auto) → onclick="Mad.rowAttach(this, 'UserForm@onEdit', {id:2}, '/app/usuarios/2/editar')". |
| Client | Mad.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). |
| Server | O 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ência | Os 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). |
| Save | closeDrawer() é 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
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];
}
}
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>
| Atributo | Tipo | Descrição |
|---|---|---|
card-view | bool | Habilita toggle tabela/cards na toolbar. |
card-default | bool | Cards como visualização padrão (abre nesse modo). |
card-cols | int | Colunas do grid de cards (default 3). |
Card roles
| card-role | Posição | Descrição |
|---|---|---|
title | topo | Título principal. Auto: primeira coluna de texto não-id/badge/money/date. |
subtitle | topo | Texto secundário. Auto: primeira coluna com date. |
badge | topo-direita | Badge no canto. Auto: primeira coluna com badge. |
highlight | destaque | Valor grande (preço). Auto: primeira coluna com money. |
image | topo | Imagem do card. Sempre explícito. |
| (vazio) | corpo | Campo 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
}
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}%");
}
};
}
}
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>
| Atributo | Tipo | Descrição |
|---|---|---|
:query | Builder|Closure | Eloquent/Query Builder pronto (ou closure que devolve um). Maior precedência. |
model / database | string | Auto-query pelo model (2ª precedência). |
:items | array | Linhas inline (3ª precedência). |
:filters | array | Filtros DSL aplicados ao Builder (QuerySource::applyArrayFilters). |
order-by / limit | string / int | "campo desc" e limite de linhas. |
group-by / group-mask | string|array | Agrupamento hierárquico (array = N níveis). |
group-total | bool | Subtotais por grupo. |
no-totals | bool | Desliga o footer de totais gerais. |
zebra | bool | Linhas alternadas (default true; desligue com :zebra="false"). |
bordered / compact | bool | Bordas em todas as células · padding menor. |
empty-text / class | string | Mensagem de vazio · classes extras na tabela. |
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
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="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];
}