Docs›Componentes (Admin)›mad-detail-form
Componentes (Admin)

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)

  1. $this->form->fill($mestre) guarda o $mestre como _sourceRecord.
  2. No render do Blade, <mad-detail-form> / <mad-field-list> com model="Item" foreign-key="mestre_id" chamam MadForm::autoLoadDetailRows().
  3. O loader monta uma query Eloquent (Item::query()->where('mestre_id', '=', $mestre->id)), ordena por id e carrega via QuerySource::recordsFromQuery().
  4. Para cada objeto, faz toArray() e — se houver hook onLoadDetail — mescla o array retornado pelo callback.
  5. FieldListColumn::normalizeRows() normaliza e popula $this->fields[$name].

Save (no onSave)

  1. $this->form->save($mestre) chama fillRecord + $mestre->save() (Eloquent) + _afterStore.
  2. _afterStore chama _autoSaveDetails(), que itera todos os details registrados via MadFormRegistry::getAutoSaveDetails() (declarados no Blade com model + foreign-key).
  3. Para cada detail, le getFieldList($name) (filtra rows totalmente vazias) e chama _saveDetailRows().
  4. Smart-sync quando as rows tem coluna id:
    • Para cada row: $model::find($pkVal) ?? new $model() (carrega por PK pra gerar UPDATE; sem PK gera INSERT), seta colunas (ignora chaves __*), seta FK, chama hook onSaveDetail se existir, faz ->save().
    • Apos processar todas: DELETE (query builder, whereNotIn('id', $savedIds)) das rows com fk = $parentId que sumiram do POST.
  5. Delete + insert quando nao tem coluna id: apaga tudo da FK e re-insere.
  6. Detail é sempre carregado/limpo só pela foreign-key — não há escopo extra além dela (ver nota sobre detailCriteria() 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 id ou id vazio: 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:model e substituido por data-df-bind na 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:

  1. 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 o hint quando não há erro). Sem o slot presente, o op html do fieldError() não teria onde escrever.
  2. :not([data-df-fields] *) no seletor master. querySelector pega 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 do dfFieldError().
  3. Drawer teleporta o sub-form. Com mode="drawer" o <x-drawer> usa x-teleport e o sub-form sai de dentro de [data-mad-df-name]. O df_field_error tem 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()