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 podnosijquery.validate.unobtrusive. Ale nigdy nie polegaj tylko na niej — zawsze sprawdźModelState.IsValidna 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.
ViewBagto 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.



