ASP.NET MVC: ¿cómo conservar los errores de ModelState en RedirectToAction?

91

Tengo los siguientes dos métodos de acción (simplificados para la pregunta):

[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"]});
   }   
}

Entonces, si la validación pasa, redirijo a otra página (confirmación).

Si ocurre un error, necesito mostrar la misma página con el error.

Si lo hago return View(), se muestra el error, pero si lo hago return RedirectToAction(como arriba), pierde los errores del modelo.

No me sorprende el problema, solo me pregunto cómo manejan esto.

Por supuesto, podría devolver la misma Vista en lugar de la redirección, pero tengo lógica en el método "Crear" que completa los datos de la vista, que tendría que duplicar.

¿Alguna sugerencia?

RPM1984
fuente
10
Resuelvo este problema al no usar el patrón Post-Redirect-Get para errores de validación. Solo uso View (). Es perfectamente válido hacer eso en lugar de saltar por un montón de aros y redirigir los problemas con el historial de su navegador.
Jimmy Bogard
2
Y además de lo que ha dicho @JimmyBogard, extraiga la lógica en el Createmétodo que completa ViewData y llámelo en el Createmétodo GET y también en la rama de validación fallida en el Createmétodo POST.
Russ Cam
1
De acuerdo, evitar el problema es una forma de solucionarlo. Tengo algo de lógica para rellenar cosas en mi Createvista, simplemente lo puse en algún método populateStuffque llamo tanto en el GETcomo en el fail POST.
Francois Joly
12
@JimmyBogard No estoy de acuerdo, si publicas en una acción y luego devuelves la vista, te encuentras con el problema en el que, si el usuario presiona actualizar, recibe la advertencia de querer iniciar esa publicación nuevamente.
The Muffin Man

Respuestas:

50

Necesita tener la misma instancia de Reviewen su HttpGetacción. Para hacer eso, debe guardar un objeto Review reviewen la variable temporal en su HttpPostacción y luego restaurarlo en la HttpGetacción.

[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"]});
   }   
}

Si desea que esto funcione incluso si el navegador se actualiza después de la primera ejecución de la HttpGetacción, puede hacer esto:

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

De lo contrario, el objeto del botón de actualización reviewestará vacío porque no habrá datos TempData["Review"].

kuncevic.dev
fuente
2
Excelente. Y un gran +1 por mencionar el problema de la actualización. Esta es la respuesta más completa, así que la aceptaré, muchas gracias. :)
RPM1984
8
Esto realmente no responde a la pregunta del título. ModelState no se conserva y eso tiene ramificaciones como la entrada HtmlHelpers que no conserva la entrada del usuario. Esto es casi una solución.
John Farrell
Terminé haciendo lo que @Wim sugirió en su respuesta.
RPM1984
17
@jfar, estoy de acuerdo, esta respuesta no funciona y no persiste en ModelState. Sin embargo, si lo modifica para que haga algo como TempData["ModelState"] = ModelState; y restaure ModelState.Merge((ModelStateDictionary)TempData["ModelState"]);, entonces funcionaría
asgeo1
1
¿No podría simplemente return Create(uniqueUri)cuando falla la validación en el POST? Dado que los valores de ModelState tienen prioridad sobre el ViewModel pasado a la vista, los datos publicados deben permanecer.
ajbeaven
83

Tuve que resolver este problema hoy yo mismo y encontré esta pregunta.

Algunas de las respuestas son útiles (usando TempData), pero realmente no responden a la pregunta en cuestión.

El mejor consejo que encontré fue en esta publicación de blog:

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

Básicamente, use TempData para guardar y restaurar el objeto ModelState. Sin embargo, es mucho más limpio si abstrae esto en atributos.

P.ej

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"]);
        }
    }
}

Luego, según su ejemplo, puede guardar / restaurar ModelState así:

[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"]});
    }   
}

Si también desea pasar el modelo en TempData (como sugirió bigb), también puede hacerlo.

asgeo1
fuente
Gracias. Implementamos algo similar a su enfoque. gist.github.com/ferventcoder/4735084
ferventcoder
Gran respuesta. Gracias.
Mark Vickery
3
Esta solución es la razón por la que uso stackoverflow. ¡Gracias hombre!
Jugg1es
@ asgeo1: gran solución, pero encontré un problema al usarlo en combinación con la repetición de vistas parciales, publiqué la pregunta aquí: stackoverflow.com/questions/28372330/…
Josh
Hermoso ejemplo de cómo tomar la solución simple y hacerla muy elegante, en el espíritu de MVC. ¡Muy agradable!
AHowgego
7

¿Por qué no crear una función privada con la lógica en el método "Create" y llamar a este método desde los métodos Get y Post y simplemente devolver View ().

Wim
fuente
Esto es en realidad lo que terminé haciendo: leíste mi mente. +1 :)
RPM1984
1
Esto es lo que hago también, solo que en lugar de tener una función privada, simplemente hago que mi método POST llame al método GET en caso de error (es decir, return Create(new { uniqueUri = ... });su lógica permanece SECA (como llamar RedirectToAction), pero sin los problemas que conlleva la redirección, como perder su ModelState.
Daniel Liuzzi
1
@DanielLiuzzi: hacerlo de esa manera no cambiará la URL. Entonces terminas con una URL similar a "/ controller / create /".
Skorunka František
@ SkorunkaFrantišek Y ese es exactamente el punto. La pregunta dice: Si ocurre un error, necesito mostrar la misma página con el error. En este contexto, es perfectamente aceptable (y preferible en mi opinión) que la URL NO cambie si se muestra la misma página. Además, una ventaja que tiene este enfoque es que si el error en cuestión no es un error de validación sino un error del sistema (tiempo de espera de la base de datos, por ejemplo), permite al usuario simplemente actualizar la página para volver a enviar el formulario.
Daniel Liuzzi
4

Podría usar TempData["Errors"]

Los TempData se pasan a través de acciones conservando los datos 1 vez.

robar waminal
fuente
4

Le sugiero que devuelva la vista y evite la duplicación mediante un atributo en la acción. A continuación, se muestra un ejemplo de cómo completar para ver datos. Podría hacer algo similar con la lógica de su método de creación.

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
}

Aquí hay un ejemplo:

[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
fuente
¿Cómo es esto una mala idea? Creo que el atributo evita la necesidad de usar otra acción porque ambas acciones pueden usar el atributo para cargar en ViewData.
CRice
1
Eche un vistazo a Post / Redirect / Get pattern: en.wikipedia.org/wiki/Post/Redirect/Get
DreamSonic
2
Eso se usa normalmente después de que se cumple la validación del modelo, para evitar más publicaciones en el mismo formulario al actualizar. Pero si el formulario tiene problemas, es necesario corregirlo y volver a publicarlo de todos modos. Esta pregunta trata sobre el manejo de errores del modelo.
CRice
Los filtros son para código reutilizable en acciones, especialmente útiles para poner cosas en ViewData. TempData es solo una solución.
CRice
1
@ppumkin tal vez intente publicar con ajax para que no tenga dificultades para reconstruir el lado del servidor de vista.
CRice
2

Tengo un método que agrega el estado del modelo a los datos temporales. Luego tengo un método en mi controlador base que verifica los datos temporales en busca de errores. Si los tiene, los vuelve a agregar a ModelState.

mella
fuente
1

Mi escenario es un poco más complicado ya que estoy usando el patrón PRG por lo que mi ViewModel ("SummaryVM") está en TempData, y mi pantalla Resumen lo muestra. Hay un pequeño formulario en esta página para PUBLICAR información a otra Acción. La complicación proviene de un requisito para que el usuario edite algunos campos en SummaryVM en esta página.

Summary.cshtml tiene el resumen de validación que detectará los errores de ModelState que crearemos.

@Html.ValidationSummary()

Mi formulario ahora necesita PUBLICARSE en una acción HttpPost para Summary (). Tengo otro ViewModel muy pequeño para representar campos editados, y modelbinding me los proporcionará.

La nueva forma:

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

y la acción ...

[HttpPost]
public ActionResult Summary(EditedItemsVM vm)

Aquí hago una validación y detecto una entrada incorrecta, por lo que necesito volver a la página Resumen con los errores. Para esto utilizo TempData, que sobrevivirá a una redirección. Si no hay ningún problema con los datos, reemplazo el objeto SummaryVM con una copia (pero con los campos editados cambiados, por supuesto) y luego hago un 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");

La acción del controlador de resumen, donde todo esto comienza, busca cualquier error en el tempdata y lo agrega al 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);
            }
        }
VictorySabre
fuente
1

Microsoft eliminó la capacidad de almacenar tipos de datos complejos en TempData, por lo tanto, las respuestas anteriores ya no funcionan; solo puede almacenar tipos simples como cadenas. He modificado la respuesta de @ asgeo1 para que funcione como se esperaba.

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);
            }
        }
    }
}

Desde aquí, simplemente puede agregar la anotación de datos requerida en un método de controlador según sea necesario.

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


[SetTempDataModelStateAttribute]
[HttpPost]
public async Task<IActionResult> MethodName()
{
    ModelState.AddModelError("KEY HERE", "ERROR HERE");
}
Alex Marchant
fuente
¡Funciona perfectamente!. Editó la respuesta para corregir un pequeño error de corchete al pegar el código.
VDWWD
0

Prefiero agregar un método a mi ViewModel que complete los valores predeterminados:

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...
    }
}

Luego lo llamo cuando necesito los datos originales 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
fuente
0

Estoy dando solo un código de muestra aquí En su viewModel, puede agregar una propiedad de tipo "ModelStateDictionary" como

public ModelStateDictionary ModelStateErrors { get; set; }

y en su menthod de acción POST puede escribir código directamente como

model.ModelStateErrors = ModelState; 

y luego asigne este modelo a Tempdata como se muestra a continuación

TempData["Model"] = model;

y cuando redirige al método de acción de otro controlador, en el controlador debe leer el valor de 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);
    }
}

Eso es. No tiene que escribir filtros de acción para esto. Esto es tan simple como el código anterior si desea obtener los errores de estado del modelo en otra vista de otro controlador.

RohanGarud
fuente