Formulários no Blazor: EditForm e Validação
Objective
Um formulário Blazor são três objetos que costumam ser vistos como um só: o
model (sua classe), o EditContext (a contabilidade: quais campos mudaram, quais
mensagens existem) e os componentes que leem os dois (InputText,
ValidationMessage, DataAnnotationsValidator). Quando essa separação fica
clara, regras customizadas, erros do servidor e inputs customizados deixam de ser
truques. Este concept roda um formulário em Interactive Server no SDK 10.0.100 e
registra os eventos do EditContext para mostrar o que valida quando, depois
acrescenta os três pontos de extensão (IValidatableObject e atributos
customizados, um ValidationMessageStore, uma subclasse de InputBase<T>) e a
mudança do .NET 10 que permite que data annotations validem objetos aninhados e
itens de coleção. Formulários enviados de páginas de static SSR estão em Blazor
Static SSR and Enhanced Navigation, e as mesmas annotations do lado da API estão
em Input Validation in Minimal APIs.
Use Cases
- Montar um formulário de criação ou edição com mensagens por campo e um resumo.
- Rejeitar um submit por um motivo que só o servidor conhece (um nome de usuário já usado).
- Validar um pedido com cliente e lista de itens a partir de um único
EditForm. - Escrever um input que interpreta um formato próprio, como
25%.
Deep Dive
Model, EditContext e o validator
O EditForm precisa de Model ou de EditContext. Com Model ele cria o
contexto para você; com EditContext você é o dono e pode assinar seus eventos.
O componente DataAnnotationsValidator é o que liga os dois: ele escuta o
contexto e roda os atributos do model. Sem ele, o EditForm só rastreia
mudanças e nada valida. Um formulário sobre um model Signup ([Required],
[StringLength(10, MinimumLength = 3)], [Range(18, 120)] em um int,
[EmailAddress] e IValidatableObject), com log nos eventos do contexto,
produziu isto quando um campo foi editado e depois o formulário foi enviado:
textOnFieldChanged Name modified=True OnValidationStateChanged messages=1 <- os atributos do campo rodaram ... OnValidationRequested <- submit: o model inteiro é validado OnValidationStateChanged messages=2 invalid submit <- OnInvalidSubmit
As regras por trás dessa saída, todas vistas na execução:
- Editar um campo valida só os atributos da propriedade desse campo. A regra
de
IValidatableObject.Validateadmin is reserved.não apareceu quandoNamevirouadmin. Ela apareceu no submit e emctx.Validate(). - Um submit dispara
OnValidationRequested, depoisOnValidSubmitouOnInvalidSubmit. UseOnSubmitse quiser chamarValidate()você mesmo. - As classes CSS
modified invalidemodified validsão adicionadas aos inputs, então estilizar não exige código. - Digitar
abcem umInputNumber<int>gerouThe Age field must be a number.do próprio componente de input, não de um atributo, e o model manteve o valor antigo. Uma falha de binding também é uma mensagem de validação.
O Blazor confirma o valor no evento change (quando o input perde o foco), não a
cada tecla, a menos que você use @bind-Value:event="oninput".
Objetos aninhados e coleções no .NET 10
Antes do .NET 10, o DataAnnotationsValidator validava as propriedades do objeto
do model e parava ali: um Customer ou uma List<OrderItem> dentro dele não era
visitado. As release notes do ASP.NET Core 10 descrevem o novo comportamento e
três passos para ativá-lo: chamar AddValidation() no Program.cs, declarar os
tipos do model em um arquivo .cs (não em um .razor, porque a saída de um
source generator não pode alimentar outro) e colocar [ValidatableType] no tipo
raiz. Os outros tipos são descobertos a partir dele.
csharp[ValidatableType]
public class Order
{
public Customer Customer { get; set; } = new();
public List<OrderItem> Items { get; set; } = [new(), new()];
}
public class Customer { [Required(ErrorMessage = "Email is required.")] public string? Email { get; set; } }
public class OrderItem { [Range(1, 10, ErrorMessage = "Quantity must be 1 to 10.")] public int Quantity { get; set; } }O mesmo formulário, executado duas vezes. Enviando um pedido sem email e com duas
quantidades 0:
textsem AddValidation : result = valid, nenhuma mensagem com AddValidation : result = invalid Email is required. Quantity must be 1 to 10. Quantity must be 1 to 10.
Depois da mudança, corrigir o email do cliente no campo removeu a mensagem dele
na hora e deixou as duas mensagens de quantidade, então os campos aninhados
também validam campo a campo. Adicionar using Microsoft.Extensions.Validation;
ao arquivo do model tornou [ValidatableType] ambíguo (CS0104) no meu projeto,
então deixe esse using de fora. As release notes também listam
[SkipValidation] para excluir uma propriedade ou tipo, uma implementação gerada
por source generator (mais amigável a trimming e AOT que a reflexão) e a mesma
ordem do Validator: primeiro as propriedades (descendo nos objetos aninhados),
depois os atributos de nível de tipo, depois IValidatableObject, e os passos
seguintes são pulados se um anterior reportar erro. Para um model em uma
biblioteca ou no projeto .Client, as notes dizem para expor um método que chama
AddValidation em um IServiceCollection e chamá-lo também no app.
O mesmo motor das minimal APIs
AddValidation é o registro usado nas minimal APIs
(Microsoft.Extensions.Validation). No mesmo app, uma minimal API que recebia o
mesmo Order devolveu isto com AddValidation ligado, e 200 com o corpo
inválido quando estava desligado:
textHTTP 400 {"errors":{"Customer.Email":["Email is required."], "Items[0].Quantity":["Quantity must be 1 to 10."]}}
Então um único conjunto de annotations serve ao formulário e ao endpoint, e as
chaves (Customer.Email, Items[0].Quantity) são os caminhos que os dois lados
usam para as mensagens. A diferença que vale lembrar: um formulário Blazor
validava as propriedades de nível superior sem AddValidation, enquanto uma
minimal API não validava nada sem ele.
Regras customizadas, erros do servidor e inputs
Três pontos de extensão, todos rodando em uma página /custom:
Um atributo customizado derivado de
ValidationAttributeque devolve umValidationResultcomc.MemberName;root is banned.apareceu na mudança do campo como qualquer regra nativa.Um
ValidationMessageStorepara erros que os atributos não têm como saber. Depois que oSaveachou o nomeanajá usado, o formulário adicionou uma mensagem a esse campo e chamouNotifyValidationStateChanged:csharpstore = new ValidationMessageStore(ctx); ctx.OnFieldChanged += (_, e) => { store.Clear(e.FieldIdentifier); ctx.NotifyValidationStateChanged(); }; // no Save: store.Add(ctx.Field(nameof(Form.UserName)), "ana is already taken."); // ctx.NotifyValidationStateChanged();A mensagem apareceu após o submit e sumiu quando o usuário editou o campo, porque o handler limpa as mensagens daquele campo; um store mantém suas mensagens até você limpá-las. Enviar
bobem seguida salvou.Uma subclasse de
InputBase<T>para um valor que não é texto simples. SobrescreverTryParseValueFromStringeFormatValueAsStringdeu um input que mostrava10%para o inteiro10, aceitava25%e, paraabc, reportavaThe Discount field must look like 50%., marcava o input comomodified invalide deixava o texto digitado na caixa:csharpprotected override string? FormatValueAsString(int v) => v + "%"; protected override bool TryParseValueFromString(string? s, out int result, [NotNullWhen(false)] out string? error) { if (s is not null && s.EndsWith('%') && int.TryParse(s.AsSpan(0, s.Length - 1), out result)) { error = null; return true; } result = default; error = $"The {DisplayName ?? FieldIdentifier.FieldName} field must look like 50%."; return false; }
Trade-offs
DataAnnotationsValidatoré simples e declarativo, com expressividade limitada. Regras que precisam de I/O (unicidade) ou de vários campos juntos pertencem aIValidatableObjectou a um message store, não a atributos.- Validação aninhada é opt-in com três condições. Esqueça o
[ValidatableType]ou ponha o model em um.razore nada aninhado é validado, sem erro que avise.csharp[ValidatableType] // só no model raiz do formulário public class Order { ... } - Regras de campo e de model disparam em momentos diferentes. Uma regra que só aparece no submit surpreende quem observa a página enquanto digita; mantenha poucas regras entre campos.
- No Interactive Server toda checagem roda no servidor. Cada mudança de campo é uma mensagem pelo circuito, então nenhum erro aparece antes da ida e volta, e as regras do formulário não substituem a validação de uma API por trás.
- Um
InputBase<T>customizado assume o parsing e a formatação. É o lugar certo para um formato, e a mensagem de erro que você escreve é sua para localizar. - Um modelo de annotations compartilhado é um acoplamento. A mesma classe validando o formulário e o endpoint evita duplicação, e também faz com que uma mudança em um seja mudança no outro.