ASP.NET MVC - Como preservar erros de ModelState no RedirectToAction?

91

Tenho os dois métodos de ação a seguir (simplificado para a pergunta):

[HttpGet]
public ActionResult Create(string uniqueUri)
{
   // get some stuff based on uniqueuri, set in ViewData.  
   return View();
}

[HttpPost]
public ActionResult Create(Review review)
{
   // validate review
   if (validatedOk)
   {
      return RedirectToAction("Details", new { postId = review.PostId});
   }  
   else
   {
      ModelState.AddModelError("ReviewErrors", "some error occured");
      return RedirectToAction("Create", new { uniqueUri = Request.RequestContext.RouteData.Values["uniqueUri"]});
   }   
}

Então, se a validação passar, redireciono para outra página (confirmação).

Se ocorrer um erro, preciso exibir a mesma página com o erro.

Se o fizer return View(), o erro será exibido, mas se o fizer return RedirectToAction(como acima), ele perderá os erros de modelo.

Não estou surpreso com o problema, apenas me perguntando como vocês lidam com isso?

Eu poderia, é claro, apenas retornar a mesma visualização em vez do redirecionamento, mas tenho lógica no método "Criar" que preenche os dados da visualização, que eu teria que duplicar.

Alguma sugestão?

RPM1984
fonte
10
Eu resolvo esse problema não usando o padrão Post-Redirect-Get para erros de validação. Acabei de usar View (). É perfeitamente válido fazer isso em vez de pular por um monte de obstáculos - e redirecionar bagunças com o histórico do seu navegador.
Jimmy Bogard
2
E além do que @JimmyBogard disse, extraia a lógica do Createmétodo que preenche ViewData e chame-o no Createmétodo GET e também no branch de validação com falha no Createmétodo POST.
Russ Cam
1
Concordo, evitar o problema é uma forma de resolvê-lo. Eu tenho alguma lógica para preencher as coisas na minha Createopinião, eu apenas coloquei em algum método populateStuffque chamo em GETe na falha POST.
François Joly
12
@JimmyBogard Eu discordo, se você postar em uma ação e, em seguida, retornar a visão, você encontrará o problema em que se o usuário clicar em Atualizar, ele receberá o aviso sobre querer iniciar aquela postagem novamente.
The Muffin Man

Respostas:

50

Você precisa ter a mesma instância de Reviewem sua HttpGetação. Para fazer isso, você deve salvar um objeto Review reviewna variável temporária em sua HttpPostação e restaurá-lo na HttpGetação.

[HttpGet]
public ActionResult Create(string uniqueUri)
{
   //Restore
   Review review = TempData["Review"] as Review;            

   // get some stuff based on uniqueuri, set in ViewData.  
   return View(review);
}
[HttpPost]
public ActionResult Create(Review review)
{
   //Save your object
   TempData["Review"] = review;

   // validate review
   if (validatedOk)
   {
      return RedirectToAction("Details", new { postId = review.PostId});
   }  
   else
   {
      ModelState.AddModelError("ReviewErrors", "some error occured");
      return RedirectToAction("Create", new { uniqueUri = Request.RequestContext.RouteData.Values["uniqueUri"]});
   }   
}

Se quiser que isso funcione mesmo que o navegador seja atualizado após a primeira execução da HttpGetação, você pode fazer o seguinte:

  Review review = TempData["Review"] as Review;  
  TempData["Review"] = review;

Caso contrário, em objeto de botão de atualização reviewestará vazio porque não haveria quaisquer dados no TempData["Review"].

kuncevic.dev
fonte
2
Excelente. E um grande +1 por mencionar o problema de atualização. Esta é a resposta mais completa, então vou aceitá-la, muito obrigado. :)
RPM1984 de
8
Isso realmente não responde à pergunta do título. ModelState não é preservado e isso tem ramificações, como HtmlHelpers de entrada, não preservando a entrada do usuário. Isso é quase uma solução alternativa.
John Farrell de
Acabei fazendo o que @Wim sugeriu em sua resposta.
RPM1984
17
@jfar, concordo, essa resposta não funciona e não persiste o ModelState. No entanto, se você modificá-lo para fazer algo semelhante TempData["ModelState"] = ModelState; e restaurá ModelState.Merge((ModelStateDictionary)TempData["ModelState"]);-lo, ele funcionará
asgeo1
1
Você não poderia apenas return Create(uniqueUri)quando a validação falha no POST? Como os valores de ModelState têm precedência sobre o ViewModel passado para a visualização, os dados postados ainda devem permanecer.
ajbeaven de
83

Eu tive que resolver esse problema sozinho hoje, e me deparei com essa pergunta.

Algumas das respostas são úteis (usando TempData), mas não respondem realmente à pergunta em questão.

O melhor conselho que encontrei foi nesta postagem do blog:

http://www.jefclaes.be/2012/06/persisting-model-state-when-using-prg.html

Basicamente, use TempData para salvar e restaurar o objeto ModelState. No entanto, é muito mais limpo se você abstrair isso em atributos.

Por exemplo

public class SetTempDataModelStateAttribute : ActionFilterAttribute
{
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        base.OnActionExecuted(filterContext);         
        filterContext.Controller.TempData["ModelState"] = 
           filterContext.Controller.ViewData.ModelState;
    }
}

public class RestoreModelStateFromTempDataAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        base.OnActionExecuting(filterContext);
        if (filterContext.Controller.TempData.ContainsKey("ModelState"))
        {
            filterContext.Controller.ViewData.ModelState.Merge(
                (ModelStateDictionary)filterContext.Controller.TempData["ModelState"]);
        }
    }
}

Então, de acordo com seu exemplo, você pode salvar / restaurar o ModelState assim:

[HttpGet]
[RestoreModelStateFromTempData]
public ActionResult Create(string uniqueUri)
{
    // get some stuff based on uniqueuri, set in ViewData.  
    return View();
}

[HttpPost]
[SetTempDataModelState]
public ActionResult Create(Review review)
{
    // validate review
    if (validatedOk)
    {
        return RedirectToAction("Details", new { postId = review.PostId});
    }  
    else
    {
        ModelState.AddModelError("ReviewErrors", "some error occured");
        return RedirectToAction("Create", new { uniqueUri = Request.RequestContext.RouteData.Values["uniqueUri"]});
    }   
}

Se você também quiser passar o modelo em TempData (como bigb sugerido), você ainda pode fazer isso também.

asgeo1
fonte
Obrigado. Implementamos algo semelhante à sua abordagem. gist.github.com/ferventcoder/4735084
ferventcoder
Ótima resposta. Obrigado.
Mark Vickery,
3
Esta solução é a razão pela qual uso stackoverflow. Obrigado cara!
jugg1es
@ asgeo1 - ótima solução, mas tive um problema ao usá-la em combinação com visualizações parciais repetidas, postei a questão aqui: stackoverflow.com/questions/28372330/…
Josh
Belo exemplo de como pegar a solução simples e torná-la muito elegante, no espírito do MVC. Muito agradável!
AHowgego
7

Por que não criar uma função privada com a lógica no método "Create" e chamar esse método de ambos os métodos Get e Post e apenas retornar View ().

Wim
fonte
Isso é realmente o que eu acabei fazendo - você leu minha mente. +1 :)
RPM1984 de
1
Isso é o que eu também faço, só que em vez de ter uma função privada, simplesmente faço meu método POST chamar o método GET em caso de erro (ou seja, return Create(new { uniqueUri = ... });sua lógica permanece DRY (muito parecido com a chamada RedirectToAction), mas sem os problemas transportados pelo redirecionamento, como perdendo seu ModelState.
Daniel Liuzzi
1
@DanielLiuzzi: fazer dessa forma não mudará a URL. Então você termina com url algo como "/ controlador / criar /".
Skorunka František
@ SkorunkaFrantišek E esse é exatamente o ponto. A questão informa Se ocorrer um erro, preciso exibir a mesma página com o erro. Neste contexto, é perfeitamente aceitável (e preferível IMO) que o URL NÃO mude se a mesma página for exibida. Além disso, uma vantagem dessa abordagem é que, se o erro em questão não for um erro de validação, mas um erro do sistema (tempo limite do banco de dados, por exemplo), ela permite que o usuário simplesmente atualize a página para reenviar o formulário.
Daniel Liuzzi
4

eu poderia usar TempData["Errors"]

TempData são passados ​​através de ações preservando dados 1 vez.

roubar waminal
fonte
4

Eu sugiro que você retorne a visualização e evite a duplicação por meio de um atributo na ação. Aqui está um exemplo de preenchimento para visualizar dados. Você poderia fazer algo semelhante com a lógica do método de criação.

public class GetStuffBasedOnUniqueUriAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var filter = new GetStuffBasedOnUniqueUriFilter();

        filter.OnActionExecuting(filterContext);
    }
}


public class GetStuffBasedOnUniqueUriFilter : IActionFilter
{
    #region IActionFilter Members

    public void OnActionExecuted(ActionExecutedContext filterContext)
    {

    }

    public void OnActionExecuting(ActionExecutingContext filterContext)
    {
        filterContext.Controller.ViewData["somekey"] = filterContext.RouteData.Values["uniqueUri"];
    }

    #endregion
}

Aqui está um exemplo:

[HttpGet, GetStuffBasedOnUniqueUri]
public ActionResult Create()
{
    return View();
}

[HttpPost, GetStuffBasedOnUniqueUri]
public ActionResult Create(Review review)
{
    // validate review
    if (validatedOk)
    {
        return RedirectToAction("Details", new { postId = review.PostId });
    }

    ModelState.AddModelError("ReviewErrors", "some error occured");
    return View(review);
}
CRice
fonte
Como isso é uma má ideia? Acho que o atributo evita a necessidade de usar outra ação porque ambas as ações podem usar o atributo para carregar em ViewData.
CRice
1
Por favor, dê uma olhada em Post / Redirect / Get pattern: en.wikipedia.org/wiki/Post/Redirect/Get
DreamSonic
2
Isso normalmente é usado após a validação do modelo ser satisfeita, para evitar mais postagens para o mesmo formulário na atualização. Mas se o formulário tiver problemas, ele precisará ser corrigido e postado novamente de qualquer maneira. Esta questão trata do tratamento de erros de modelo.
CRice
Os filtros são para código reutilizável em ações, especialmente úteis para colocar coisas em ViewData. TempData é apenas uma solução alternativa.
CRice
1
@ppumkin talvez tente postar com ajax para que você não tenha dificuldade em reconstruir seu servidor de exibição.
CRice
2

Eu tenho um método que adiciona o estado do modelo aos dados temporários. Então, tenho um método em meu controlador de base que verifica se há erros nos dados temporários. Se tiver, ele os adiciona de volta ao ModelState.

usuario
fonte
1

Meu cenário é um pouco mais complicado porque estou usando o padrão PRG, então meu ViewModel ("SummaryVM") está em TempData e minha tela de resumo o exibe. Há um pequeno formulário nesta página para POSTAR algumas informações para outra ação. A complicação veio da necessidade de o usuário editar alguns campos no SummaryVM nesta página.

Summary.cshtml tem o resumo de validação que detectará erros de ModelState que criaremos.

@Html.ValidationSummary()

Meu formulário agora precisa ser POST em uma ação HttpPost para Summary (). Eu tenho outro ViewModel muito pequeno para representar os campos editados, e modelbinding vai trazer isso para mim.

O novo formulário:

@using (Html.BeginForm("Summary", "MyController", FormMethod.Post))
{
    @Html.Hidden("TelNo") @* // Javascript to update this *@

e a ação ...

[HttpPost]
public ActionResult Summary(EditedItemsVM vm)

Aqui eu faço algumas validações e detecto algumas entradas incorretas, então preciso retornar à página Resumo com os erros. Para isso eu uso TempData, que sobreviverá a um redirecionamento. Se não houver problemas com os dados, substituo o objeto SummaryVM por uma cópia (mas com os campos editados alterados, é claro) e, em seguida, faço um RedirectToAction ("NextAction");

// Telephone number wasn't in the right format
List<string> listOfErrors = new List<string>();
listOfErrors.Add("Telephone Number was not in the correct format. Value supplied was: " + vm.TelNo);
TempData["SummaryEditedErrors"] = listOfErrors;
return RedirectToAction("Summary");

A ação do controlador de Resumo, onde tudo isso começa, procura por quaisquer erros no tempdata e os adiciona ao modelstate.

[HttpGet]
[OutputCache(Duration = 0)]
public ActionResult Summary()
{
    // setup, including retrieval of the viewmodel from TempData...


    // And finally if we are coming back to this after a failed attempt to edit some of the fields on the page,
    // load the errors stored from TempData.
        List<string> editErrors = new List<string>();
        object errData = TempData["SummaryEditedErrors"];
        if (errData != null)
        {
            editErrors = (List<string>)errData;
            foreach(string err in editErrors)
            {
                // ValidationSummary() will see these
                ModelState.AddModelError("", err);
            }
        }
VictorySaber
fonte
1

A Microsoft removeu a capacidade de armazenar tipos de dados complexos em TempData, portanto, as respostas anteriores não funcionam mais; você só pode armazenar tipos simples como strings. Alterei a resposta de @ asgeo1 para funcionar conforme o esperado.

public class SetTempDataModelStateAttribute : ActionFilterAttribute
{
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        base.OnActionExecuted(filterContext);

        var controller = filterContext.Controller as Controller;
        var modelState = controller?.ViewData.ModelState;
        if (modelState != null)
        {
            var listError = modelState.Where(x => x.Value.Errors.Any())
                .ToDictionary(m => m.Key, m => m.Value.Errors
                .Select(s => s.ErrorMessage)
                .FirstOrDefault(s => s != null));
            controller.TempData["KEY HERE"] = JsonConvert.SerializeObject(listError);
        }
    }
}


public class RestoreModelStateFromTempDataAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        base.OnActionExecuting(filterContext);

        var controller = filterContext.Controller as Controller;
        var tempData = controller?.TempData?.Keys;
        if (controller != null && tempData != null)
        {
            if (tempData.Contains("KEY HERE"))
            {
                var modelStateString = controller.TempData["KEY HERE"].ToString();
                var listError = JsonConvert.DeserializeObject<Dictionary<string, string>>(modelStateString);
                var modelState = new ModelStateDictionary();
                foreach (var item in listError)
                {
                    modelState.AddModelError(item.Key, item.Value ?? "");
                }

                controller.ViewData.ModelState.Merge(modelState);
            }
        }
    }
}

A partir daqui, você pode simplesmente adicionar a anotação de dados necessária em um método do controlador conforme necessário.

[RestoreModelStateFromTempDataAttribute]
[HttpGet]
public async Task<IActionResult> MethodName()
{
}


[SetTempDataModelStateAttribute]
[HttpPost]
public async Task<IActionResult> MethodName()
{
    ModelState.AddModelError("KEY HERE", "ERROR HERE");
}
Alex Marchant
fonte
Funciona perfeitamente!. Editou a resposta para corrigir um pequeno erro de colchete ao colar o código.
VDWWD
0

Prefiro adicionar um método ao meu ViewModel que preencha os valores padrão:

public class RegisterViewModel
{
    public string FirstName { get; set; }
    public IList<Gender> Genders { get; set; }
    //Some other properties here ....
    //...
    //...

    ViewModelType PopulateDefaultViewData()
    {
        this.FirstName = "No body";
        this.Genders = new List<Gender>()
        {
            Gender.Male,
            Gender.Female
        };

        //Maybe other assinments here for other properties...
    }
}

Então eu chamo isso sempre que preciso dos dados originais, como este:

    [HttpGet]
    public async Task<IActionResult> Register()
    {
        var vm = new RegisterViewModel().PopulateDefaultViewValues();
        return View(vm);
    }

    [HttpPost]
    public async Task<IActionResult> Register(RegisterViewModel vm)
    {
        if (!ModelState.IsValid)
        {
            return View(vm.PopulateDefaultViewValues());
        }

        var user = await userService.RegisterAsync(
            email: vm.Email,
            password: vm.Password,
            firstName: vm.FirstName,
            lastName: vm.LastName,
            gender: vm.Gender,
            birthdate: vm.Birthdate);

        return Json("Registered successfully!");
    }
Mohammed Noureldin
fonte
0

Estou dando apenas um exemplo de código aqui. Em seu viewModel, você pode adicionar uma propriedade do tipo "ModelStateDictionary" como

public ModelStateDictionary ModelStateErrors { get; set; }

e em seu método de ação POST, você pode escrever código diretamente como

model.ModelStateErrors = ModelState; 

e então atribuir este modelo a Tempdata como abaixo

TempData["Model"] = model;

e quando você redireciona para o método de ação de outro controlador, então no controlador você tem que ler o valor Tempdata

if (TempData["Model"] != null)
{
    viewModel = TempData["Model"] as ViewModel; //Your viewmodel class Type
    if(viewModel.ModelStateErrors != null && viewModel.ModelStateErrors.Count>0)
    {
        this.ViewData.ModelState.Merge(viewModel.ModelStateErrors);
    }
}

É isso aí. Você não precisa escrever filtros de ação para isso. Isso é tão simples quanto o código acima se você deseja obter os erros de estado do modelo para outra visualização de outro controlador.

RohanGarud
fonte