mad-detail-form
Detail-form auto load/save com hooks.
<mad-detail-form> e <mad-field-list> declarados com model + foreign-key no Blade sao auto-carregados no render e auto-salvos pelo $this->form->save($mestre). Hooks onLoadDetail e onSaveDetail permitem transformar/validar cada linha sem perder o automatismo.
Como funciona
Load (no onEdit)
$this->form->fill($mestre)guarda o$mestrecomo_sourceRecord.- No render do Blade,
<mad-detail-form>/<mad-field-list>commodel="Item"foreign-key="mestre_id"chamamMadForm::autoLoadDetailRows(). - O loader monta uma query Eloquent (
Item::query()->where('mestre_id', '=', $mestre->id)), ordena poride carrega viaQuerySource::recordsFromQuery(). - Para cada objeto, faz
toArray()e — se houver hookonLoadDetail— mescla o array retornado pelo callback. FieldListColumn::normalizeRows()normaliza e popula$this->fields[$name].
Save (no onSave)
$this->form->save($mestre)chamafillRecord+$mestre->save()(Eloquent) +_afterStore._afterStorechama_autoSaveDetails(), que itera todos os details registrados viaMadFormRegistry::getAutoSaveDetails()(declarados no Blade commodel+foreign-key).- Para cada detail, le
getFieldList($name)(filtra rows totalmente vazias) e chama_saveDetailRows(). - Smart-sync quando as rows tem coluna
id:- Para cada row:
$model::find($pkVal) ?? new $model()(carrega por PK pra gerarUPDATE; sem PK geraINSERT), seta colunas (ignora chaves__*), seta FK, chama hookonSaveDetailse existir, faz->save(). - Apos processar todas:
DELETE(query builder,whereNotIn('id', $savedIds)) das rows comfk = $parentIdque sumiram do POST.
- Para cada row:
- Delete + insert quando nao tem coluna
id: apaga tudo da FK e re-insere. - Detail é sempre carregado/limpo só pela
foreign-key— não há escopo extra além dela (ver nota sobredetailCriteria()abaixo).
Hook onLoadDetail — transformar rows ao carregar
Assinatura: fn($child, $master): array — retorna campos extras para mesclar na row.
Util para trazer campos derivados de relacionamentos que nao existem na tabela do filho.
public function onEdit(int $id): void
{
$pedido = PedidoVenda::findOrFail($id); // leitura nao precisa de transacao
$this->pedidoId = (int) $pedido->id;
// Hook ANTES do fill — registra o transform
$this->form->onLoadDetail('itens', fn($item, $pedido) => [
'familia_produto_id' => $item->produto_id ? ($item->produto->familia_produto_id ?? '') : '',
]);
$this->form->fill($pedido);
}
Hook onSaveDetail — transformar/validar linhas ao salvar
Assinatura: fn($child, $master, $row): void — modifica $child (instancia ja com colunas setadas + FK), ou acumula no $master.
Util para calcular totais por item, normalizar valores, acumular agregados no mestre.
public function onSave(): MadResponse
{
try {
$data = $this->form->getData();
$this->form->validate(PedidoVenda::rules($this->pedidoId));
DB::connection('business')->transaction(function () {
$pedido = $this->pedidoId ? PedidoVenda::findOrFail($this->pedidoId) : new PedidoVenda();
$pedido->valor_total = 0;
// Hook ANTES do save — calcula valor_total por item e acumula no mestre
$this->form->onSaveDetail('itens', function ($item, $pedido, $row): void {
if (!$item->desconto) {
$item->desconto = 0;
}
$item->valor_total = ((float) $item->valor - (float) $item->desconto) * (float) $item->quantidade;
if ($item->valor_total < 0) {
$item->valor_total = 0;
}
$pedido->valor_total += $item->valor_total;
});
$this->form->save($pedido);
$pedido->save(); // re-persiste o valor_total acumulado pelo hook (ver nota abaixo)
});
return (new MadResponse())
->toast('Pedido salvo!', 'success')
->closeDrawer();
} catch (MadValidationException $e) {
return $e->asModal();
} catch (Throwable $e) {
return MadMessage::error('Erro', $e->getMessage());
}
}
IMPORTANTE: o hook precisa ser registrado antes do form->save(). A ordem de execucao do hook e: setar colunas → setar FK → hook → $instance->save(). Ou seja, dentro do hook ja existe $item->produto_id, $item->mestre_id (FK) etc.
Como o _autoSaveDetails so roda dentro do _afterStore (que e o passo final do save), o valor_total do mestre sera computado depois do primeiro save(). Para refletir o agregado, e necessario um segundo save() no fim, OU acumular antes via getFieldList() manualmente. Para totais simples, prefira computar depois e re-salvar:
$this->form->save($pedido);
// Re-persiste agregados acumulados pelo hook
$pedido->save();
Escopo extra para load + save (sem detailCriteria())
A versão atual do framework removeu o método detailCriteria(). Tanto o
auto-load/auto-save declarativo (model+foreign-key no Blade) quanto o
helper MadFieldListTrait::loadDetailRows()/saveDetailItems() (ver seção
abaixo) escopam o detail só pela foreign-key — nenhum dos dois aceita
um WHERE extra.
Se o filho compartilha tabela com outros tipos (ex: tipo = 'entrada') e
você precisa de um filtro adicional no load, monte a query você mesmo e
injete o resultado direto em $this->form->fields[$name] (mesmo array que o
auto-load preenche), normalizando com FieldListColumn::normalizeRows():
use Mad\Form\FieldListColumn;
public function onEdit(int $id): void
{
$conta = Conta::findOrFail($id);
$this->contaId = (int) $conta->id;
$this->form->fill($conta);
$rows = Lancamento::query()
->where('conta_id', '=', $conta->id)
->where('tipo', '=', 'entrada')
->orderBy('ordem')
->get()
->map(fn ($l) => $l->toArray())
->all();
$this->form->fields['lancamentos'] = FieldListColumn::normalizeRows($rows);
}
No onSave, o smart-sync automático (form->save($mestre)) também não tem
como saber do filtro tipo = 'entrada' — ele só evita apagar linhas que não
batem com a foreign-key. Se outros tipos realmente compartilham a mesma
tabela e podem colidir, prefira não declarar model/foreign-key no
Blade e persistir manualmente dentro de onSave, filtrando o delete:
public function onSave(): MadResponse
{
$data = $this->form->getData();
DB::connection('business')->transaction(function () use ($data) {
$conta = $this->contaId ? Conta::findOrFail($this->contaId) : new Conta();
$conta->save();
Lancamento::where('conta_id', '=', $conta->id)
->where('tipo', '=', 'entrada')
->delete(); // só apaga o escopo "entrada" — preserva os outros tipos
foreach ($data->lancamentos as $row) {
Lancamento::create(array_merge($row, ['conta_id' => $conta->id, 'tipo' => 'entrada']));
}
});
return (new MadResponse())->toast('Salvo!', 'success')->closeDrawer();
}
Quando usar hooks vs manual
| Cenario | Abordagem |
|---|---|
| Detail simples (campos do filho mapeiam 1:1 com inputs) | Apenas declarar model + foreign-key no Blade |
| Trazer campos derivados na carga (relacionamento) | onLoadDetail() |
| Calcular/normalizar valores por linha antes de persistir | onSaveDetail() |
| Acumular totais no mestre | onSaveDetail() + segundo save() no mestre |
| Filtrar por tipo/escopo extra | Manual — sem model/foreign-key no Blade, query própria + $this->form->fields[$name] (ver acima) |
Detail sem model/foreign-key no Blade |
MadFieldListTrait + loadDetailRows() / saveDetailItems() manual |
Comportamento do smart-sync
- Rows com coluna
id(ja existentes carregadas via auto-load): update. - Rows sem
idouidvazio: insert novo. - Rows que sumiram do POST: delete (via
id NOT IN (savedIds)). - Rows totalmente vazias (todos os campos vazios, exceto chaves
__*): ignoradas. - Chaves comecando com
__(ex:__mad_*): nunca sao setadas no model. - A FK e sempre forcada para
$mestre->{pk}— nao da pra mudar via input.
Layout do form de entrada (<mad-detail-fields>)
O conteudo de <mad-detail-fields> e renderizado como Blade arbitrario em runtime (via MadBlade::renderString()). Ou seja, qualquer componente de layout do MAD pode ser usado livremente: <mad-form-grid>, <mad-form-stack>, <mad-form-section>, <mad-tabs>, <mad-accordion>, <mad-card>, <mad-separator>, etc. Os inputs em qualquer profundidade (incluindo abas ocultas via x-show) sao coletados pelo framework via querySelectorAll('[name]') dentro do container [data-df-fields], entao o layout nao interfere na coleta.
Auto-wrap em <mad-form-grid> (default)
Quando o usuario nao declara nenhum container de layout, o compiler envolve automaticamente o conteudo em <mad-form-grid :cols="{form-cols}"> (padrao 2 colunas). A detecao reconhece: mad-form-grid, mad-form-stack, mad-form-section, mad-tabs, mad-tabs-list, mad-accordion, mad-card — se qualquer um desses esta presente, o auto-wrap nao acontece.
Opt-out: form-layout="custom"
Para desligar o auto-wrap explicitamente (ex: quando o layout comeca com <div> custom ou qualquer outra tag fora da lista conhecida), use form-layout="custom" no <mad-detail-form>:
<mad-detail-form name="itens" form-layout="custom" model="PedidoItem" foreign-key="pedido_id" mode="drawer">
<mad-detail-fields>
<div class="minha-estrutura-custom">
<mad-input-field name="produto" label="Produto" />
<mad-number-field name="quantidade" label="Qtd" />
</div>
</mad-detail-fields>
<mad-columns>
<mad-col field="produto" label="Produto" />
<mad-col field="quantidade" label="Qtd" right />
</mad-columns>
</mad-detail-form>
Exemplo: tabs por grupo
<mad-detail-form name="itens" model="PedidoItem" foreign-key="pedido_id" mode="drawer" form-title="Item do pedido">
<mad-detail-fields>
<mad-tabs default="dados" variant="underline">
<mad-tabs-list>
<mad-tab name="dados" icon="package">Dados</mad-tab>
<mad-tab name="medidas" icon="ruler">Medidas</mad-tab>
<mad-tab name="obs" icon="message-square">Observacoes</mad-tab>
</mad-tabs-list>
<mad-tab-panel name="dados">
<mad-form-grid :cols="2">
<mad-dbcombo-field name="produto_id" label="Produto" model="Produto" display="nome" required />
<mad-number-field name="quantidade" label="Qtd" step="0.001" required />
</mad-form-grid>
</mad-tab-panel>
<mad-tab-panel name="medidas">
<mad-form-grid :cols="3">
<mad-numeric-field name="largura" label="Largura" suffix="m" />
<mad-numeric-field name="altura" label="Altura" suffix="m" />
<mad-numeric-field name="profundidade" label="Profundidade" suffix="m" />
</mad-form-grid>
</mad-tab-panel>
<mad-tab-panel name="obs">
<mad-textarea-field name="obs" label="Observacoes" :rows="4" />
</mad-tab-panel>
</mad-tabs>
</mad-detail-fields>
<mad-columns>
<mad-col field="produto" label="Produto" sort />
<mad-col field="quantidade" label="Qtd" right />
</mad-columns>
</mad-detail-form>
Exemplo: multi-section com separador
<mad-detail-form name="itens" model="PedidoItem" foreign-key="pedido_id" mode="drawer">
<mad-detail-fields>
<mad-form-section title="Produto" icon="package">
<mad-form-grid :cols="2">
<mad-dbcombo-field name="produto_id" label="Produto" model="Produto" display="nome" required />
<mad-number-field name="quantidade" label="Qtd" required />
</mad-form-grid>
</mad-form-section>
<mad-separator />
<mad-form-section title="Preco" icon="dollar-sign">
<mad-form-grid :cols="2">
<mad-money-field name="valor" label="Unitario" />
<mad-money-field name="desconto" label="Desconto" />
</mad-form-grid>
</mad-form-section>
</mad-detail-fields>
...
</mad-detail-form>
Exemplo: form-stack (campos empilhados, sem grid)
<mad-detail-form name="anotacoes" model="PedidoAnotacao" foreign-key="pedido_id" mode="modal">
<mad-detail-fields>
<mad-form-stack>
<mad-input-field name="titulo" label="Titulo" required />
<mad-textarea-field name="conteudo" label="Conteudo" :rows="6" required />
</mad-form-stack>
</mad-detail-fields>
...
</mad-detail-form>
Regras praticas
- Inputs dentro de qualquer container sao coletados automaticamente (basta ter
name). mad:modele substituido pordata-df-bindna string inteira — funciona em qualquer profundidade.- Abas ocultas (
x-show="false") mantem os inputs no DOM, entao o add/edit preserva todos os valores. <mad-col>e<mad-act>continuam fora de<mad-detail-fields>— dentro sao ignorados (sao configs da grid de exibicao, nao do form).
Canal de erro em compostos (5.x)
Master e detail podem ter campos com o mesmo name. Por isso o erro de
campo tem dois canais distintos no MadResponse:
| Método | Alvo | Seletor efetivo |
|---|---|---|
fieldError($campo, $msg) |
Campo do form master | [data-field-error="campo"]:not([data-df-fields] *) |
dfFieldError($dfName, $campo, $msg) |
Campo dentro do detail-form $dfName |
resolvido no cliente, escopado ao container do detail |
clearFieldError($campo) |
Limpa o erro do campo master | idem fieldError |
public function onSave(): MadResponse
{
if (empty($_POST['cliente_id'])) {
return (new MadResponse())->fieldError('cliente_id', 'Informe o cliente');
}
foreach ($this->form->getFieldList('itens') as $row) {
if ((float) ($row['quantidade'] ?? 0) <= 0) {
return (new MadResponse())->dfFieldError('itens', 'quantidade', 'Quantidade deve ser > 0');
}
}
// ...
}
Três garantias que sustentam esse canal:
- O slot de erro está SEMPRE no DOM. Os componentes de campo renderizam
<p class="mad-field-hint" data-field-error="{{ $name }}">mesmo sem erro (ele carrega ohintquando não há erro). Sem o slot presente, oophtmldofieldError()não teria onde escrever. :not([data-df-fields] *)no seletor master.querySelectorpega o PRIMEIRO match do documento — um detail-form com campo homônimo antes do campo master roubaria o erro. A exclusão do escopo[data-df-fields]impede isso; erro dentro do detail é responsabilidade dodfFieldError().- Drawer teleporta o sub-form. Com
mode="drawer"o<x-drawer>usax-teleporte o sub-form sai de dentro de[data-mad-df-name]. Odf_field_errortem fallback global por[data-df-fields][data-df-name="<nome>"]para achar o container teleportado.
Aba inativa auto-ativa
Se o campo com erro estiver num painel de <mad-tabs> que não é a aba ativa,
o slot existe no DOM mas fica invisível (x-show é só CSS). O applyOps
detecta que o target do op é um data-field-error e chama
_revealFieldError(), que sobe até o .mad-tabs e seta activeTab para o
data-mad-tab do painel — a aba certa abre sozinha. Nada a declarar no
Blade.
NUNCA fazer
// ERRADO: registrar hook DEPOIS do save/fill — nao tem efeito
$this->form->save($pedido);
$this->form->onSaveDetail('itens', fn(...) => ...); // tarde demais
// CERTO: registrar ANTES
$this->form->onSaveDetail('itens', fn(...) => ...);
$this->form->save($pedido);
// ERRADO: chamar saveDetailItems() manualmente quando o Blade ja tem model+foreign-key
// → roda 2x: uma pelo auto-save e outra pelo manual
$this->form->save($pedido);
$this->saveDetailItems('PedidoItem', 'pedido_id', $pedido->id, $data->itens);
// CERTO: confiar no auto-save
$this->form->save($pedido);
// ERRADO: alterar a FK dentro do hook — o valor REALMENTE muda (a FK e setada
// ANTES do hook rodar, e nada a refaz depois disso), entao isso reatribui a
// linha pra outro pai silenciosamente e quebra o smart-sync (a linha some do
// escopo do mestre original na proxima carga). Nunca faca isso.
$this->form->onSaveDetail('itens', function ($item, $pedido) {
$item->pedido_id = 999; // PERIGOSO: persiste de verdade, nao e ignorado
});
// ERRADO: tentar usar $pedido->valor_total agregado dentro do hook acreditando que sera persistido
// → o save() do mestre ja rodou; precisa de um segundo $pedido->save() apos o form->save()