ASP.NET MVC - Comment conserver les erreurs ModelState dans RedirectToAction?

91

J'ai les deux méthodes d'action suivantes (simplifiées pour la question):

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

Donc, si la validation réussit, je redirige vers une autre page (confirmation).

Si une erreur se produit, je dois afficher la même page avec l'erreur.

Si je le fais return View(), l'erreur est affichée, mais si je le fais return RedirectToAction(comme ci-dessus), il perd les erreurs de modèle.

Je ne suis pas surpris par le problème, je me demande simplement comment vous gérez cela?

Je pourrais bien sûr simplement retourner la même vue au lieu de la redirection, mais j'ai une logique dans la méthode "Create" qui remplit les données de vue, que je devrais dupliquer.

Aucune suggestion?

RPM1984
la source
10
Je résous ce problème en n'utilisant pas le modèle Post-Redirect-Get pour les erreurs de validation. J'utilise juste View (). C'est parfaitement valable de le faire au lieu de sauter à travers un tas de cerceaux - et de rediriger les dégâts avec l'historique de votre navigateur.
Jimmy Bogard
2
Et en plus de ce que @JimmyBogard a dit, extrayez la logique de la Createméthode qui remplit ViewData et appelez-la dans la Createméthode GET et également dans la branche de validation échouée dans la Createméthode POST.
Russ Cam
1
D'accord, éviter le problème est une façon de le résoudre. J'ai une certaine logique pour remplir les choses à mon Createavis, je l'ai simplement mis dans une méthode populateStuffque j'appelle à la fois le GETet le fail POST.
Francois Joly
12
@JimmyBogard Je ne suis pas d'accord, si vous publiez sur une action, puis renvoyez la vue que vous rencontrez dans le problème où si l'utilisateur frappe Actualiser, il reçoit l'avertissement de vouloir relancer ce message.
The Muffin Man

Réponses:

50

Vous devez avoir la même instance de Reviewsur votre HttpGetaction. Pour ce faire, vous devez enregistrer un objet Review reviewdans la variable temporaire sur votre HttpPostaction, puis le restaurer sur HttpGetaction.

[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 vous souhaitez que cela fonctionne même si le navigateur est actualisé après la première exécution de l' HttpGetaction, vous pouvez le faire:

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

Sinon, sur le bouton d'actualisation, l'objet reviewsera vide car il n'y aurait aucune donnée dans TempData["Review"].

kuncevic.dev
la source
2
Excellent. Et un gros +1 pour avoir mentionné le problème d'actualisation. C'est la réponse la plus complète donc je vais l'accepter, merci beaucoup. :)
RPM1984
8
Cela ne répond pas vraiment à la question du titre. ModelState n'est pas conservé et cela a des ramifications telles que les HtmlHelpers d'entrée ne préservant pas l'entrée utilisateur. C'est presque une solution de contournement.
John Farrell
J'ai fini par faire ce que @Wim suggérait dans sa réponse.
RPM1984
17
@jfar, je suis d'accord, cette réponse ne fonctionne pas et ne persiste pas le ModelState. Cependant, si vous le modifiez pour qu'il fasse quelque chose comme TempData["ModelState"] = ModelState; et que vous le restaurez avec ModelState.Merge((ModelStateDictionary)TempData["ModelState"]);, cela fonctionnerait
asgeo1
1
Pourriez-vous pas juste return Create(uniqueUri)quand la validation échoue sur le POST? Comme les valeurs ModelState sont prioritaires sur le ViewModel transmis à la vue, les données publiées doivent toujours rester.
ajbeaven
83

J'ai dû résoudre ce problème moi-même aujourd'hui et je suis tombé sur cette question.

Certaines des réponses sont utiles (en utilisant TempData), mais ne répondent pas vraiment à la question posée.

Le meilleur conseil que j'ai trouvé était sur ce billet de blog:

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

En gros, utilisez TempData pour enregistrer et restaurer l'objet ModelState. Cependant, c'est beaucoup plus propre si vous résumez cela en attributs.

Par exemple

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

Ensuite, selon votre exemple, vous pouvez enregistrer / restaurer le ModelState comme ceci:

[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 vous souhaitez également transmettre le modèle dans TempData (comme le suggère bigb), vous pouvez toujours le faire.

asgeo1
la source
Je vous remercie. Nous avons mis en œuvre quelque chose de similaire à votre approche. gist.github.com/ferventcoder/4735084
ferventcoder
Très bonne réponse. Merci.
Mark Vickery
3
Cette solution est la raison pour laquelle j'utilise stackoverflow. Merci mec!
jugg1es
@ asgeo1 - excellente solution, mais j'ai rencontré un problème en l'utilisant en combinaison avec la répétition de vues partielles, j'ai posté la question ici: stackoverflow.com/questions/28372330/…
Josh
Bel exemple de prendre la solution simple et de la rendre très élégante, dans l'esprit MVC. Très agréable!
AHowgego
7

Pourquoi ne pas créer une fonction privée avec la logique de la méthode "Create" et appeler cette méthode à la fois à partir de la méthode Get et Post et simplement renvoyer View ().

Wim
la source
C'est en fait ce que j'ai fini par faire - vous lisez dans mes pensées. +1 :)
RPM1984
1
C'est ce que je fais aussi, mais au lieu d'avoir une fonction privée, ma méthode POST appelle simplement la méthode GET en cas d'erreur (c'est-à-dire que return Create(new { uniqueUri = ... });votre logique reste DRY (un peu comme appeler RedirectToAction), mais sans les problèmes liés à la redirection, tels que perdre votre ModelState.
Daniel Liuzzi
1
@DanielLiuzzi: cela ne changera pas l'URL. Donc, vous terminez par une URL quelque chose comme "/ controller / create /".
Skorunka František
@ SkorunkaFrantišek Et c'est exactement le but. La question indique Si une erreur se produit, je dois afficher la même page avec l'erreur. Dans ce contexte, il est parfaitement acceptable (et préférable IMO) que l'URL ne change PAS si la même page est affichée. De plus, un avantage de cette approche est que si l'erreur en question n'est pas une erreur de validation mais une erreur système (délai d'expiration de la base de données par exemple), elle permet à l'utilisateur de simplement actualiser la page pour soumettre à nouveau le formulaire.
Daniel Liuzzi
4

je pourrais utiliser TempData["Errors"]

Les TempData sont transmises à travers les actions préservant les données 1 fois.

Rob Waminal
la source
4

Je vous propose de retourner la vue, et d'éviter la duplication via un attribut sur l'action. Voici un exemple de remplissage pour afficher des données. Vous pouvez faire quelque chose de similaire avec votre logique de méthode de création.

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
}

Voici un exemple:

[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
la source
En quoi est-ce une mauvaise idée? Je pense que l'attribut évite le besoin d'utiliser une autre action car les deux actions peuvent utiliser l'attribut pour charger dans ViewData.
CRice
1
Veuillez consulter le modèle Post / Redirect / Get: en.wikipedia.org/wiki/Post/Redirect/Get
DreamSonic
2
Ceci est normalement utilisé une fois que la validation du modèle est satisfaite, pour empêcher d'autres publications sur le même formulaire lors de l'actualisation. Mais si le formulaire a des problèmes, il doit quand même être corrigé et republié. Cette question traite de la gestion des erreurs de modèle.
CRice
Les filtres sont pour le code réutilisable sur les actions, particulièrement utiles pour mettre des choses dans ViewData. TempData est juste une solution de contournement.
CRice
1
@ppumkin peut peut-être essayer de publier avec ajax afin de ne pas avoir de mal à reconstruire votre côté serveur de vue.
CRice
2

J'ai une méthode qui ajoute l'état du modèle aux données temporaires. J'ai ensuite une méthode dans mon contrôleur de base qui vérifie les données temporaires pour toute erreur. S'il les a, il les rajoute à ModelState.

pseudo
la source
1

Mon scénario est un peu plus compliqué car j'utilise le modèle PRG donc mon ViewModel ("SummaryVM") est dans TempData, et mon écran Summary l'affiche. Il y a un petit formulaire sur cette page pour POSTER des informations sur une autre action. La complication vient de l'obligation pour l'utilisateur de modifier certains champs dans SummaryVM sur cette page.

Summary.cshtml a le résumé de validation qui détectera les erreurs ModelState que nous allons créer.

@Html.ValidationSummary()

Mon formulaire doit maintenant POSTER sur une action HttpPost pour Summary (). J'ai un autre très petit ViewModel pour représenter les champs modifiés, et le modelbinding me les fournira.

Le nouveau formulaire:

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

et l'action ...

[HttpPost]
public ActionResult Summary(EditedItemsVM vm)

Ici, je fais une validation et je détecte une mauvaise entrée, je dois donc retourner à la page Résumé avec les erreurs. Pour cela, j'utilise TempData, qui survivra à une redirection. S'il n'y a pas de problème avec les données, je remplace l'objet SummaryVM par une copie (mais avec les champs modifiés bien sûr modifiés) puis fais 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");

L'action de contrôleur Résumé, là où tout cela commence, recherche les erreurs dans les données temp et les ajoute à l'état du modèle.

[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);
            }
        }
VictoireSabre
la source
1

Microsoft a supprimé la possibilité de stocker des types de données complexes dans TempData, par conséquent, les réponses précédentes ne fonctionnent plus; vous ne pouvez stocker que des types simples comme des chaînes. J'ai modifié la réponse de @ asgeo1 pour qu'elle fonctionne comme prévu.

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

À partir de là, vous pouvez simplement ajouter l'annotation de données requise sur une méthode de contrôleur si nécessaire.

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


[SetTempDataModelStateAttribute]
[HttpPost]
public async Task<IActionResult> MethodName()
{
    ModelState.AddModelError("KEY HERE", "ERROR HERE");
}
Alex Marchant
la source
Fonctionne parfaitement!. Modification de la réponse pour corriger une petite erreur entre crochets lors du collage du code.
VDWWD
0

Je préfère ajouter une méthode à mon ViewModel qui remplit les valeurs par défaut:

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

Ensuite, je l'appelle chaque fois que j'ai besoin des données originales comme celle-ci:

    [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
la source
0

Je donne juste un exemple de code ici Dans votre viewModel, vous pouvez ajouter une propriété de type "ModelStateDictionary" comme

public ModelStateDictionary ModelStateErrors { get; set; }

et dans votre méthode d'action POST, vous pouvez écrire du code directement comme

model.ModelStateErrors = ModelState; 

puis attribuez ce modèle à Tempdata comme ci-dessous

TempData["Model"] = model;

et lorsque vous redirigez vers la méthode d'action d'un autre contrôleur, vous devez lire la valeur Tempdata dans le contrôleur

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

C'est tout. Vous n'êtes pas obligé d'écrire des filtres d'action pour cela. C'est aussi simple que le code ci-dessus si vous souhaitez obtenir des erreurs d'état du modèle vers une autre vue d'un autre contrôleur.

RohanGarud
la source