ASP.NET MVC - Jak zachować błędy ModelState w ramach RedirectToAction?

92

Mam następujące dwie metody działania (uproszczone do pytania):

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

Jeśli więc walidacja przejdzie pomyślnie, przekierowuję na inną stronę (potwierdzenie).

Jeśli wystąpi błąd, muszę wyświetlić tę samą stronę z błędem.

Jeśli to zrobię return View(), błąd jest wyświetlany, ale jeśli to zrobię return RedirectToAction(jak powyżej), powoduje utratę błędów modelu.

Nie jestem zaskoczony problemem, po prostu zastanawiam się, jak sobie z tym radzicie?

Mógłbym oczywiście po prostu zwrócić ten sam widok zamiast przekierowania, ale mam logikę w metodzie „Utwórz”, która zapełnia dane widoku, które musiałbym powielić.

Jakieś sugestie?

RPM1984
źródło
10
Rozwiązuję ten problem, nie używając wzorca Post-Redirect-Get do błędów walidacji. Po prostu używam View (). Jest to całkowicie słuszne, aby to zrobić zamiast skakać przez kilka kółek - i przekierowywać bałagan w historii przeglądarki.
Jimmy Bogard,
2
Oprócz tego, co powiedział @JimmyBogard, wyodrębnij logikę w Createmetodzie, która wypełnia ViewData i wywołaj ją w Createmetodzie GET, a także w nieudanej gałęzi walidacji w Createmetodzie POST.
Russ Cam,
1
Zgoda, unikanie problemu jest jednym ze sposobów jego rozwiązania. Mam pewną logikę do wypełniania rzeczy Create, po prostu umieściłem to w jakiejś metodzie populateStuff, którą wywołuję zarówno w poleceniu, jak GETi niepowodzeniu POST.
Francois Joly,
12
@JimmyBogard Nie zgadzam się, jeśli wyślesz wiadomość do akcji, a następnie zwrócisz widok, w którym napotkasz problem, w którym użytkownik odświeży się, otrzyma ostrzeżenie o chęci ponownego zainicjowania tego postu.
The Muffin Man

Odpowiedzi:

50

Musisz mieć to samo wystąpienie Reviewna swojej HttpGetakcji. Aby to zrobić, należy zapisać obiekt Review revieww zmiennej tymczasowej w HttpPostakcji, a następnie przywrócić go w HttpGetakcji.

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

Jeśli chcesz, aby to działało, nawet jeśli przeglądarka jest odświeżana po pierwszym wykonaniu HttpGetakcji, możesz to zrobić:

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

W przeciwnym razie obiekt przycisku odświeżania reviewbędzie pusty, ponieważ nie będzie w nim żadnych danych TempData["Review"].

kuncevic.dev
źródło
2
Świetny. I duże +1 za wspomnienie o problemie z odświeżaniem. To jest najbardziej kompletna odpowiedź, więc przyjmuję ją, wielkie dzięki. :)
RPM1984
8
To tak naprawdę nie odpowiada na pytanie w tytule. ModelState nie jest zachowywany i ma konsekwencje, takie jak wejście HtmlHelpers, które nie zachowuje wpisu użytkownika. To prawie obejście.
John Farrell,
W końcu zrobiłem to, co zasugerował @Wim w swojej odpowiedzi.
RPM1984,
17
@jfar, zgadzam się, ta odpowiedź nie działa i nie utrwala ModelState. Jeśli jednak zmodyfikujesz go tak , aby działał jak TempData["ModelState"] = ModelState; i przywróci z ModelState.Merge((ModelStateDictionary)TempData["ModelState"]);, to zadziała
asgeo1
1
Czy nie mógłbyś tylko return Create(uniqueUri)wtedy, gdy weryfikacja nie powiedzie się w POST? Ponieważ wartości ModelState mają pierwszeństwo przed ViewModel przekazanym do widoku, opublikowane dane powinny nadal pozostać.
ajbeaven
84

Musiałem dzisiaj sam rozwiązać ten problem i natknąłem się na to pytanie.

Niektóre odpowiedzi są przydatne (przy użyciu TempData), ale tak naprawdę nie odpowiadają na zadane pytanie.

Najlepsza rada, jaką znalazłem, znajduje się w tym wpisie na blogu:

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

Zasadniczo użyj TempData, aby zapisać i przywrócić obiekt ModelState. Jednak jest to o wiele czystsze, jeśli przeniesiesz to na atrybuty.

Na przykład

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

Następnie, zgodnie z przykładem, możesz zapisać / przywrócić ModelState w następujący sposób:

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

Jeśli chcesz również przekazać model w TempData (jak zasugerował bigb), nadal możesz to zrobić.

asgeo1
źródło
Dziękuję Ci. Wdrożyliśmy coś podobnego do Twojego podejścia. gist.github.com/ferventcoder/4735084
ferventcoder
@ asgeo1 - świetne rozwiązanie, ale napotkałem problem podczas używania go w połączeniu z powtarzaniem częściowych widoków, opublikowałem tutaj pytanie: stackoverflow.com/questions/28372330/…
Josh
Ostrzeżenie - jeśli strona jest obsługiwana w całości w jednym żądaniu (i nie jest podzielona przez AJAX), pytasz o problemy z używaniem tego rozwiązania, ponieważ TempData jest zachowywana aż do następnego żądania. Na przykład: wprowadzasz kryteria wyszukiwania na jednej stronie, następnie PRG do wyników wyszukiwania, a następnie klikasz łącze, aby bezpośrednio przejść z powrotem do strony wyszukiwania, oryginalne wartości wyszukiwania zostaną ponownie wypełnione. Pojawiają się też inne dziwne i czasami trudne do odtworzenia zachowanie.
PJ7
Nie mogłem wykonać tej pracy, dopóki nie zdałem sobie sprawy, że identyfikator sesji ciągle się zmienia. To pomogło mi rozwiązać ten problem: stackoverflow.com/a/5835631/1185136
Rudey
P: Co się dzieje NextRequesti TempDatazachowuje się, gdy istnieje wiele kart przeglądarki wykonujących (wiele / jednoczesnych) żądań?
dan
7

Dlaczego nie utworzyć funkcji prywatnej z logiką w metodzie „Create” i wywołać tę metodę zarówno z metody Get, jak i Post, i po prostu zwrócić View ().

Wim
źródło
1
To też robię, tylko zamiast mieć funkcję prywatną, po prostu mam metodę POST wywołującą metodę GET w przypadku błędu (tj return Create(new { uniqueUri = ... });. Twoja logika pozostaje SUCHA (podobnie jak wywołanie RedirectToAction), ale bez problemów przenoszonych przez przekierowanie, takich jak utrata swojego ModelState
Daniel Liuzzi
1
@DanielLiuzzi: zrobienie tego w ten sposób nie zmieni adresu URL. Więc kończysz z adresem URL, w stylu „/ controller / create /”.
Skorunka František
@ SkorunkaFrantišek I o to właśnie chodzi. Pytanie brzmi: Jeśli wystąpi błąd, muszę wyświetlić tę samą stronę z błędem. W tym kontekście jest całkowicie dopuszczalne (i preferowane IMO), aby adres URL NIE zmieniał się, jeśli ta sama strona jest wyświetlana. Ponadto jedną z zalet tego podejścia jest to, że jeśli dany błąd nie jest błędem walidacji, ale błędem systemowym (na przykład przekroczeniem limitu czasu bazy danych), umożliwia użytkownikowi po prostu odświeżenie strony w celu ponownego przesłania formularza.
Daniel Liuzzi
4

mógłbym użyć TempData["Errors"]

TempData są przekazywane przez akcje zachowujące dane 1 raz.

rob waminal
źródło
4

Proponuję zwrócić widok i unikać powielania za pomocą atrybutu w akcji. Oto przykład wypełniania w celu wyświetlenia danych. Możesz zrobić coś podobnego z logiką metody tworzenia.

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
}

Oto przykład:

[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
źródło
Dlaczego to zły pomysł? Myślę, że atrybut pozwala uniknąć konieczności użycia innej akcji, ponieważ obie akcje mogą używać atrybutu do załadowania do ViewData.
CRice
1
Zapoznaj się z wzorcem Post / Redirect / Get: en.wikipedia.org/wiki/Post/Redirect/Get
DreamSonic
2
Jest to zwykle używane po pomyślnym sprawdzeniu poprawności modelu, aby zapobiec kolejnym postom w tym samym formularzu podczas odświeżania. Ale jeśli formularz zawiera błędy, i tak należy go poprawić i ponownie opublikować. To pytanie dotyczy obsługi błędów modelu.
CRice
Filtry są przeznaczone do wielokrotnego użytku kodu akcji, szczególnie przydatne do umieszczania rzeczy w ViewData. TempData to tylko obejście.
CRice
1
@ppumkin może spróbować opublikować post za pomocą Ajax, abyś nie miał trudności z przebudową strony serwera widoku.
CRice
2

Mam metodę, która dodaje stan modelu do danych tymczasowych. Mam wtedy metodę w moim kontrolerze podstawowym, która sprawdza dane tymczasowe pod kątem błędów. Jeśli je ma, dodaje je z powrotem do ModelState.

nacięcie
źródło
1

Mój scenariusz jest trochę bardziej skomplikowany, ponieważ używam wzorca PRG, więc mój ViewModel („SummaryVM”) jest w TempData, a mój ekran Podsumowanie wyświetla go. Na tej stronie znajduje się mały formularz, w którym można WYSŁAĆ pewne informacje do innej akcji. Powikłanie wynika z konieczności edytowania przez użytkownika niektórych pól w SummaryVM na tej stronie.

Summary.cshtml zawiera podsumowanie walidacji, które będzie wychwytywać błędy ModelState, które utworzymy.

@Html.ValidationSummary()

Mój formularz musi teraz POST do akcji HttpPost dla Summary (). Mam inny bardzo mały ViewModel do reprezentowania edytowanych pól, a powiązanie modelu dostarczy je do mnie.

Nowa forma:

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

i akcja ...

[HttpPost]
public ActionResult Summary(EditedItemsVM vm)

Tutaj przeprowadzam weryfikację i wykrywam złe dane wejściowe, więc muszę wrócić do strony Podsumowanie z błędami. Do tego używam TempData, który przetrwa przekierowanie. Jeśli nie ma problemu z danymi, zamieniam obiekt SummaryVM na kopię (ale oczywiście ze zmienionymi edytowanymi polami), a następnie wykonuję 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");

Akcja kontrolera podsumowania, od której wszystko się zaczyna, wyszukuje wszelkie błędy w tempdata i dodaje je do stanu modelu.

[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
źródło
1

Firma Microsoft usunęła możliwość przechowywania złożonych typów danych w TempData, dlatego poprzednie odpowiedzi już nie działają; możesz przechowywać tylko proste typy, takie jak ciągi. Zmieniłem odpowiedź przez @ asgeo1, aby działała zgodnie z oczekiwaniami.

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

W tym miejscu możesz po prostu dodać wymaganą adnotację danych do metody kontrolera, zgodnie z potrzebą.

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


[SetTempDataModelStateAttribute]
[HttpPost]
public async Task<IActionResult> MethodName()
{
    ModelState.AddModelError("KEY HERE", "ERROR HERE");
}
Alex Marchant
źródło
Działa świetnie!. Zmodyfikowano odpowiedź, aby naprawić mały błąd nawiasu podczas wklejania kodu.
VDWWD
0

Wolę dodać metodę do mojego ViewModel, która wypełnia wartości domyślne:

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

Wtedy nazywam to, gdy potrzebuję oryginalnych danych w ten sposób:

    [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
źródło
0

Podaję tutaj przykładowy kod W swoim viewModel możesz dodać jedną właściwość typu "ModelStateDictionary" jako

public ModelStateDictionary ModelStateErrors { get; set; }

a w metodzie działania POST możesz pisać kod bezpośrednio jak

model.ModelStateErrors = ModelState; 

a następnie przypisz ten model do Tempdata, jak poniżej

TempData["Model"] = model;

a kiedy przekierowujesz do metody akcji innego kontrolera, w kontrolerze musisz odczytać wartość 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);
    }
}

Otóż ​​to. Nie musisz w tym celu pisać filtrów akcji. Jest to tak proste, jak powyższy kod, jeśli chcesz przenieść błędy stanu modelu do innego widoku innego kontrolera.

RohanGarud
źródło