Programação Por Equipe DevBras

Capa do artigo: Lista dinâmica no ASP.NET Core: índice e Razor

Lista dinâmica no ASP.NET Core: índice e Razor

Linhas que o usuário adiciona e remove no ASP.NET Core: por que índice com buraco some no model binding e como o campo Index com chave própria resolve.

Tela de pedido com linhas que o usuário adiciona e remove é onde o model binding do ASP.NET Core mais surpreende. O C# está certo, o EF Core está certo, e mesmo assim a lista chega ao servidor sem metade dos itens. Quase sempre o culpado é o HTML: os nomes dos campos que o JavaScript montou depois que a página carregou.

Este post fica no lado do navegador. Primeiro, um teste com curl que mostra quais nomes o binder aceita. Depois, uma página Razor com um <template> e um JavaScript curto que adiciona e remove linhas sem furar a lista. Tudo foi testado com .NET 10 e Chrome em modo headless. O que o servidor faz com a lista recebida (atualizar, inserir, apagar e recusar Id de outro pedido) está no guia para salvar a lista editável com EF Core.

Seis formulários, seis resultados

Para enxergar o que o binder monta, uma action de teste devolve a lista que recebeu, item por item, no formato Id:Produto:Quantidade:

public class ItemForm
{
    public int Id { get; set; }
    public string Produto { get; set; } = "";
    public int Quantidade { get; set; }
    public bool Remover { get; set; }
}
public class PedidoForm { public List<ItemForm> Itens { get; set; } = new(); }

[HttpPost("/eco")]
public IActionResult Eco([FromForm] PedidoForm form) =>
    Ok(form.Itens.Select(i => $"{i.Id}:{i.Produto}:{i.Quantidade}"));

Com a aplicação rodando em localhost, cada caso é um curl -d com os campos do formulário:

eco "0, 1, 2"            -d 'Itens[0].Produto=Parafuso' -d 'Itens[1].Produto=Bucha' -d 'Itens[2].Produto=Broca'
eco "0 e 2 (linha do meio removida)" -d 'Itens[0].Produto=Parafuso' -d 'Itens[2].Produto=Broca'
eco "começa em 1"        -d 'Itens[1].Produto=Bucha' -d 'Itens[2].Produto=Broca'
eco "nome sem índice"    -d 'Itens.Produto=Parafuso' -d 'Itens.Produto=Bucha'
eco "Index com chaves livres" -d 'Itens.Index=a' -d 'Itens[a].Produto=Parafuso' -d 'Itens.Index=k7f2' -d 'Itens[k7f2].Produto=Arruela'
eco "chave no Index sem campos" -d 'Itens.Index=a' -d 'Itens[a].Produto=Parafuso' -d 'Itens.Index=b'

O resultado, com o status HTTP na frente:

# 0, 1, 2
200 ["0:Parafuso:0","0:Bucha:0","0:Broca:0"]
# 0 e 2 (linha do meio removida)
200 ["0:Parafuso:0"]
# começa em 1
200 []
# nome sem índice
200 []
# Index com chaves livres
200 ["0:Parafuso:0","0:Arruela:0"]
# chave no Index sem campos
500 System.NullReferenceException: Object reference not set to an instance of an object.   at

Os três primeiros casos seguem a regra que a documentação de model binding da Microsoft deixa escrita: índices numéricos precisam começar em zero e ser sequenciais, e tudo o que vem depois de um buraco é ignorado. Remover a linha do meio no DOM, com Itens[0] e Itens[2] sobrando, faz a broca sumir. Uma lista que começa em 1 (comum quando o índice vem de um contador que começa no número da linha) chega vazia, sem erro e sem aviso.

O quarto caso pega quem escreve name="Itens.Produto" num foreach sem índice. Esse formato repetido funciona para lista de tipo simples, como int[], mas não para lista de objetos: o binder não tem como saber qual Produto combina com qual Quantidade.

O quinto é a saída: o campo Itens.Index. Cada linha declara a própria chave, que pode ser qualquer texto, e o binder junta os campos Itens[chave].* na ordem em que as chaves aparecem. Sem sequência, sem buraco possível.

O sexto mostra o efeito colateral: uma chave declarada em Itens.Index sem nenhum campo correspondente vira um item null dentro da lista, e o primeiro i.Id estoura NullReferenceException. Acontece quando o JavaScript remove os inputs de uma linha mas esquece o input hidden da chave.

A página: cada linha carrega a própria chave

Na página Razor, a chave de uma linha que já existe no banco sai do Id (i1, i2...). Assim ela é estável entre um carregamento e outro:

<form id="pedido" method="post" action="/pedidos/@Model.Pedido.Id">
  <table>
    <tbody id="linhas">
    @foreach (var item in Model.Pedido.Itens)
    {
      var k = $"i{item.Id}";
      <tr>
        <td>
          <input type="hidden" name="Itens.Index" value="@k">
          <input type="hidden" name="Itens[@k].Id" value="@item.Id">
          <input name="Itens[@k].Produto" value="@item.Produto">
        </td>
        <td><input type="number" name="Itens[@k].Quantidade" value="@item.Quantidade"></td>
        <td>
          <input type="hidden" name="Itens[@k].Remover" value="false">
          <button type="button" data-remover>Remover</button>
        </td>
      </tr>
    }
    </tbody>
  </table>
  <button type="button" id="adicionar">Adicionar item</button>
  <button type="submit">Salvar</button>
</form>

Duas escolhas importam aqui. O botão de remover é type="button", senão ele envia o formulário. E cada linha tem um hidden Remover com false: em item que já está no banco, apagar a linha do DOM não diz nada ao servidor, que não tem como diferenciar "a pessoa removeu" de "o campo se perdeu no caminho". Marcar Remover=true é um pedido explícito.

A linha nova vem de um <template>, com __k__ no lugar da chave e Id 0:

<template id="modelo-linha">
  <tr>
    <td>
      <input type="hidden" name="Itens.Index" value="__k__">
      <input type="hidden" name="Itens[__k__].Id" value="0">
      <input name="Itens[__k__].Produto">
    </td>
    <td><input type="number" name="Itens[__k__].Quantidade" value="1"></td>
    <td>
      <input type="hidden" name="Itens[__k__].Remover" value="false">
      <button type="button" data-remover>Remover</button>
    </td>
  </tr>
</template>

O JavaScript: adicionar troca a chave, remover depende da origem

<script>
  const linhas = document.getElementById("linhas");
  const modelo = document.getElementById("modelo-linha");

  document.getElementById("adicionar").addEventListener("click", () => {
    const k = "n" + crypto.randomUUID().slice(0, 8);
    linhas.insertAdjacentHTML("beforeend", modelo.innerHTML.replaceAll("__k__", k));
  });

  linhas.addEventListener("click", (e) => {
    const botao = e.target.closest("[data-remover]");
    if (!botao) return;
    const linha = botao.closest("tr");
    if (linha.querySelector('[name$=".Id"]').value === "0") {
      linha.remove();                 // linha nova: some com chave e tudo
    } else {
      linha.querySelector('[name$=".Remover"]').value = "true";
      linha.hidden = true;            // item do banco: vai com Remover=true
    }
  });
</script>

Adicionar copia o template trocando __k__ por uma chave aleatória. crypto.randomUUID() só existe em contexto seguro (HTTPS ou localhost), o que cobre produção e desenvolvimento. Remover tem dois comportamentos. Linha nova sai inteira do DOM, com o hidden de Itens.Index junto, e isso evita o item null do sexto caso. Linha que veio do banco fica escondida com Remover=true.

O teste no navegador

Para conferir o conjunto, um script de teste carregado só com ?autoteste na URL clica em "Remover" na Bucha, adiciona a Arruela com 20 unidades, adiciona outra linha e a remove antes de salvar, muda a Broca para 2 e envia o formulário duas vezes: para a action de eco e para a action que salva. Rodando no Chrome headless contra o pedido 1093 (Parafuso, Bucha e Broca 6mm), o formulário saiu com 4 chaves e este corpo (trecho):

Itens.Index=i2
Itens[i2].Id=2
Itens[i2].Produto=Bucha
Itens[i2].Quantidade=10
Itens[i2].Remover=true
...
Itens.Index=n787da13a
Itens[n787da13a].Id=0
Itens[n787da13a].Produto=Arruela
Itens[n787da13a].Quantidade=20
Itens[n787da13a].Remover=false
ECO ["1:Parafuso:10","2:Bucha:10","3:Broca 6mm:2","0:Arruela:20"]
SALVO 200 ["1:Parafuso:10","3:Broca 6mm:2","10:Arruela:20"]

A linha desistida não deixou rastro nenhum, a Bucha chegou marcada para remoção e a Arruela entrou com Id 0, que o servidor transformou no item 10. É isso que se quer ver antes de abrir para o usuário.

Detalhes que quebram em produção

  • Não chame nada de Index perto da lista. A mesma página da Microsoft avisa que um parâmetro ou propriedade chamado index ao lado de uma coleção é usado pelo binder como índice da coleção. Uma action Post(string index, List<Item> itens) mistura as duas coisas.
  • Linha escondida continua no formulário. hidden só esconde. Se você usa validação no cliente com required, o navegador pode travar o envio por causa de um campo invisível. Tire o required ao marcar a remoção.
  • Vue ou outro framework dentro da página. A regra é a mesma: o framework precisa gerar os nomes Itens.Index e Itens[chave].Campo. Se a tela já usa Vue, veja como manter a ilha de Vue dentro do Razor sem perder o envio normal do formulário.
  • Id no hidden não é confiável. O usuário consegue editar o valor e mandar o Id de um item de outro pedido. A defesa fica no servidor e é o assunto do guia de salvamento com EF Core.


Avalie este artigo

Referência: https://stackoverflow.com/questions/23693084/modelbinder-creates-new-instance-of-collection-item-instead-of-updating-existing