Formularze i walidacja w ASP.NET Core MVC

Jak zbudować formularz w ASP.NET Core MVC, obsłużyć HttpGet i HttpPost, dodać walidację modelu oraz wyciągnąć powtarzalny kod do Partial View.

JAJarosław Armatys 9 min czytania
Wersja wideo tego wpisu — obejrzyj na YouTube

To czwarta lekcja kursu podstaw ASP.NET Core MVC i tym razem wchodzimy w temat, bez którego żadna aplikacja webowa nie ma sensu — formularze. Pokażę ci tutaj, jak zbudować formularz oparty o tag helpery, obsłużyć poprawnie metody GET i POST, dodać walidację modelu przez atrybuty oraz jak nie duplikować kodu, korzystając z Partial View.

Zakładam, że masz już za sobą poprzednie odcinki — wiesz, co to kontroler, widok i model, i rozumiesz cykl życia zapytania w MVC. Jeśli nie, wróć najpierw do wcześniejszych lekcji, bo tutaj lecimy dalej.

Formularz to most między widokiem a kontrolerem

Zaczynamy od prostego scenariusza: chcemy dodać nowy produkt do magazynu. Tworzę więc kontroler WarehouseController, a w nim metodę Add, która na razie zwraca tylko widok — to nasz punkt wyjścia. Do tej metody dodaję link w menu, żeby wygodnie do niej trafić, i przechodzę do tworzenia samego widoku.

Widok silnie typuję modelem Product — dzięki temu razor wie, na jakich polach pracujemy, a ja mam pełne podpowiadanie w edytorze. W środku widoku ląduje <form> z odpowiednimi tag helperami. To jest kluczowa sprawa: ASP.NET Core MVC pozwala ci napisać formularz, który sam wygeneruje poprawne name, id i value dla każdego pola, a przy okazji spięcie z modelem działa automatycznie w obie strony.

@model TestApp.Models.Product

<h2>Dodaj produkt do magazynu</h2>

<form asp-controller="Warehouse" asp-action="Add" method="post">
    <label>Nazwa</label>
    <input asp-for="Name" />

    <label>Opis</label>
    <input asp-for="Description" />

    <label>Kategoria</label>
    <input asp-for="Category" />

    <button type="submit">Wyślij formularz</button>
</form>

asp-for="Name" robi tu całą robotę. Razor domyśli się, że chodzi o właściwość Name z modelu Product, wygeneruje odpowiednie name="Name" i id="Name", a przy re-renderze widoku podstawi poprzednią wartość. Jeśli chcesz dodać statyczny tekst obok pola — pamiętaj, że razor sam go nie rozpozna jako literał; użyj wtedy @: albo wrzuć w znacznik HTML.

Tag helpery wyglądają jak zwykłe atrybuty HTML, ale pod spodem są to serwery — Razor renderuje je w locie, patrząc na twój model. To właśnie dlatego zmiana nazwy właściwości w modelu od razu podnosi błąd kompilacji w widoku.

HttpGet vs HttpPost — dwie akcje pod jednym adresem

Jeśli wejdziesz w link w menu, przeglądarka wysyła GET. Jeśli klikniesz „Wyślij formularz” z method="post" — idzie POST. Obie te akcje trafiają pod ten sam adres /Warehouse/Add, więc w kontrolerze musimy mieć dwie metody: jedną do wyrenderowania pustego formularza, drugą do przyjęcia danych.

public class WarehouseController : Controller
{
    [HttpGet]
    public IActionResult Add()
    {
        return View();
    }

    [HttpPost]
    public IActionResult Add(Product product)
    {
        if (!ModelState.IsValid)
        {
            return View(product);
        }

        // tu leci logika zapisu produktu do bazy

        return RedirectToAction(nameof(Add));
    }
}

Zwróć uwagę na dwie rzeczy. Po pierwsze — kompilator by się wściekł na dwie metody o tej samej sygnaturze, ale skoro druga przyjmuje Product, parametry są różne, więc jest OK. Po drugie — po sukcesie NIE zwracam View(), tylko robię RedirectToAction. To jest wzorzec Post/Redirect/Get i chroni cię przed problemem, w którym użytkownik odświeża stronę po wysłaniu formularza, a przeglądarka grzecznie wysyła POST drugi raz — i do bazy lecą duplikaty.

Binding działa automatycznie: to, co użytkownik wpisał w pola Name, Description, Category, ląduje w odpowiednich właściwościach obiektu Product. To ModelBinder robi za ciebie mapowanie form-data → obiekt C#.

Walidacja przez atrybuty i ModelState.IsValid

Najprościej walidować formularz za pomocą atrybutów z przestrzeni System.ComponentModel.DataAnnotations. Wrzucasz je bezpośrednio na pola modelu i gotowe:

using System.ComponentModel.DataAnnotations;

public class Product
{
    public int Id { get; set; }

    [Required(ErrorMessage = "Pole nazwa jest wymagane")]
    public string Name { get; set; }

    [Required]
    public string Description { get; set; }

    [Required]
    public string Category { get; set; }
}

Tylko to jeszcze nie wszystko. Same atrybuty nic nie zrobią, dopóki ich nie sprawdzisz po stronie kontrolera. Robi to ModelState.IsValid — property typu bool, które na podstawie atrybutów modelu mówi ci, czy dane przyszły poprawne.

Jeśli ModelState.IsValid jest false, wracasz do widoku z tym samym modelem (return View(product)), a dzięki silnemu typowaniu formularza użytkownik zobaczy to, co wpisał — nie tracimy poprawnie wypełnionych pól, a tylko wymuszamy poprawkę tych, które są złe.

Żeby pokazać użytkownikowi, co konkretnie poszło nie tak, w widoku dorzucamy ValidationSummary:

<div class="validation-summary">
    @Html.ValidationSummary(false, "", new { @class = "text-danger" })
</div>

<form asp-controller="Warehouse" asp-action="Add" method="post">
    <label>Nazwa</label>
    <input asp-for="Name" />
    <span asp-validation-for="Name" class="text-danger"></span>
    ...
</form>

ValidationSummary wyświetla listę wszystkich błędów z ModelState. Jeśli chcesz komunikat tylko przy jednym polu — użyj <span asp-validation-for="Name">. Domyślne komunikaty lecą po angielsku, ale wystarczy ustawić ErrorMessage w atrybucie i masz własny tekst.

Walidacja klient-side (to, co od razu wyświetla przeglądarka bez strzału do serwera) to bonus — tag helpery generują odpowiednie atrybuty data-val-*, które podnosi jquery.validate.unobtrusive. Ale nigdy nie polegaj tylko na niej — zawsze sprawdź ModelState.IsValid na serwerze.

TempData, ViewBag, ViewData — kiedy którego użyć

Przy wzorcu Post/Redirect/Get pojawia się typowy problem: chcesz po zapisie pokazać komunikat „produkt dodany”, ale robisz RedirectToAction, przez co tracisz kontekst. ViewBag i ViewData żyją w ramach jednego requestu, więc po redirectcie ich nie ma. Od tego jest TempData — zapisywana jest w sesji (albo w cookie) i przeżywa dokładnie jeden redirect.

[HttpPost]
public IActionResult Add(Product product)
{
    if (!ModelState.IsValid)
    {
        return View(product);
    }

    // zapis do bazy

    TempData["SuccessMessage"] = $"Produkt '{product.Name}' został dodany";
    return RedirectToAction(nameof(Add));
}

A w widoku:

@if (TempData["SuccessMessage"] != null)
{
    <div class="alert alert-success">
        @TempData["SuccessMessage"]
    </div>
}

Krótki ściągacz:

  • ViewBag / ViewData — ten sam słownik, różna składnia. Żyją przez czas jednego requestu. Dobre do przekazania pobocznych danych z kontrolera do widoku (np. tytuł strony, lista dropdowna).
  • TempData — przeżywa redirect. Dobre do komunikatów typu „zapisano”, „usunięto”, flash messages.
  • Model silnie typowany — zawsze preferuj. ViewBag to szybka łatka, nie architektura.

Partial View — gdy nie chcesz się powtarzać

Na koniec jeszcze jedna użyteczna sprawa. Jeśli masz formularz, który chcesz mieć w kilku miejscach na stronie, nie kopiuj go trzy razy. Wyciągnij do Partial View.

Tworzysz plik _ProductForm.cshtml, najlepiej w folderze Views/Shared/, żeby był widoczny z każdego kontrolera. Wrzucasz do niego tę samą zawartość (pamiętając o @model Product na górze), a w głównym widoku renderujesz go tag helperem:

<partial name="_ProductForm" model="Model" />

Kluczowa sprawa: Partial też musi być silnie typowany i musisz mu podać model przez atrybut model="Model". Dzięki temu edycja w jednym miejscu propaguje się wszędzie, gdzie ten partial jest użyty — dorzucisz nowe pole i od razu masz je we wszystkich formularzach.

Co dalej?

Masz już działający formularz z walidacją, poprawnie obsłużonym POST-em i partialem do reużycia. W kolejnej lekcji przechodzimy do warstwy pośredniczącej — pokażę ci dependency injection w ASP.NET Core, jak zarejestrować własny serwis i jak przenieść logikę biznesową z kontrolera, żeby kontroler robił tylko to, do czego jest — czyli przyjmował request i zwracał odpowiedź.

Kod z tej lekcji leży na GitHubie (gałąź Lekcja-4). Jeśli coś nie działa albo chcesz, żebym rozwinął temat walidacji (custom attribute, FluentValidation, walidacja asynchroniczna) — wal w komentarz pod filmem.

Tagi:#course-aspnet#dotnet#mvc#forms

Masz pytanie do tego wpisu?

Napisz do mnie — chętnie porozmawiam o szczegółach technicznych, albo o tym jak zastosować to u Ciebie w projekcie.