PageModel: логіка сторінки Razor Pages
PageModel: логіка сторінки Razor Pages
PageModel — це серце кожної Razor Page. Це клас, де живе вся логіка сторінки: обробка GET/POST запитів, отримання даних, перевірка валідації, перенаправлення. Ні більше, ні менше.
Якщо ви звикли до Minimal API, ось найближча аналогія:
// Minimal API: lambda-хендлер
app.MapGet("/products/{id}", async (int id, ProductService svc) =>
{
var product = await svc.GetByIdAsync(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
// Razor Pages: PageModel
public class DetailsModel : PageModel
{
private readonly ProductService _svc;
public DetailsModel(ProductService svc) => _svc = svc;
public Product? Product { get; private set; }
public async Task<IActionResult> OnGetAsync(int id)
{
Product = await _svc.GetByIdAsync(id);
return Product is null ? NotFound() : Page();
}
}
Ті самі концепції: DI через конструктор, отримання даних, повернення результату. Але замість Results.* — Page(), NotFound(), RedirectToPage().
Handler-методи: OnGet, OnPost та інші
Razor Pages автоматично викликає методи PageModel залежно від HTTP-методу запиту:
| HTTP Method | Метод PageModel |
|---|---|
GET | OnGet() / OnGetAsync() |
POST | OnPost() / OnPostAsync() |
PUT | OnPut() / OnPutAsync() |
DELETE | OnDelete() / OnDeleteAsync() |
PATCH | OnPatch() / OnPatchAsync() |
HEAD | OnHead() / OnHeadAsync() |
public class IndexModel : PageModel
{
private readonly ProductService _svc;
private readonly ILogger<IndexModel> _logger;
public IndexModel(ProductService svc, ILogger<IndexModel> logger)
{
_svc = svc;
_logger = logger;
}
// Власивість доступна у .cshtml через Model.Products
public List<Product> Products { get; private set; } = [];
// ─── GET /products ─────────────────────────────────────────
// Повертає void або Task якщо завжди → Page()
public async Task OnGetAsync()
{
_logger.LogInformation("Loading products list");
Products = await _svc.GetAllAsync();
// Implicit return Page() — завжди рендеримо Index.cshtml
}
}
public class CreateModel : PageModel
{
private readonly ProductService _svc;
public CreateModel(ProductService svc) => _svc = svc;
// ─── GET /products/create ───────────────────────────────────
public void OnGet()
{
// Просто показуємо порожню форму — нічого не робимо
// (можна заповнити dropdown-и, категорії тощо)
}
// ─── POST /products/create ──────────────────────────────────
// Повертає IActionResult якщо можливі різні результати (Page або Redirect)
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
// Є помилки валідації — показуємо форму знову
return Page();
}
await _svc.CreateAsync(Input);
// Успіх → редирект на список (Post-Redirect-Get патерн)
return RedirectToPage("./Index");
}
}
BindProperty: зв'язування даних з форм
[BindProperty] — аналог [FromBody] у Minimal API, але для HTML-форм. Атрибут автоматично наповнює властивість даними з POST-форми:
public class CreateModel : PageModel
{
// BindProperty: автоматично заповнюється при POST
// Дані з HTML form input="Product.Name" → цей об'єкт
[BindProperty]
public CreateProductInput Input { get; set; } = new();
public async Task<IActionResult> OnPostAsync()
{
// Input вже заповнений даними з форми
// ModelState вже перевірений за DataAnnotations
if (!ModelState.IsValid)
return Page();
await _svc.CreateAsync(
new Product { Name = Input.Name, Price = Input.Price });
return RedirectToPage("./Index");
}
}
// DTO для вводу — з DataAnnotations для валідації
public class CreateProductInput
{
[Required(ErrorMessage = "Назва обов'язкова")]
[MaxLength(100, ErrorMessage = "Максимум 100 символів")]
public string Name { get; set; } = "";
[Required]
[Range(0.01, 999_999, ErrorMessage = "Ціна має бути від 0.01 до 999,999")]
public decimal Price { get; set; }
[MaxLength(500)]
public string? Description { get; set; }
}
BindProperty(SupportsGet = true)
За замовчуванням [BindProperty] працює тільки з POST. Для GET (параметри пошуку, пайджинація) — SupportsGet = true:
public class IndexModel : PageModel
{
// Аналог [FromQuery] у Minimal API
// Заповнюється з ?search=coffee&page=2
[BindProperty(SupportsGet = true)]
public string? Search { get; set; }
[BindProperty(SupportsGet = true)]
public int Page { get; set; } = 1;
[BindProperty(SupportsGet = true)]
public int PageSize { get; set; } = 10;
public List<Product> Products { get; private set; } = [];
public int TotalPages { get; private set; }
public async Task OnGetAsync()
{
// Search, Page, PageSize вже заповнені з query string
var (products, total) = await _svc.SearchAsync(Search, Page, PageSize);
Products = products;
TotalPages = (int)Math.Ceiling(total / (double)PageSize);
}
}
<!-- Форма пошуку — GET запит -->
<form method="get">
<input asp-for="Search" class="form-control" placeholder="Пошук...">
<button type="submit">Знайти</button>
</form>
<!-- Пагінація як посилання -->
@for (int i = 1; i <= Model.TotalPages; i++)
{
<a asp-page="./Index"
asp-route-search="@Model.Search"
asp-route-page="@i"
class="btn @(i == Model.Page ? "btn-primary" : "btn-outline-primary")">
@i
</a>
}
Параметри з маршруту: @page "{id:int}"
// @page "{id:int}" у .cshtml файлі
// → /products/edit/42
public class EditModel : PageModel
{
[BindProperty]
public EditProductInput Input { get; set; } = new();
// id приходить з маршруту — як параметр методу
public async Task<IActionResult> OnGetAsync(int id)
{
var product = await _svc.GetByIdAsync(id);
if (product is null) return NotFound();
// Заповнюємо форму поточними значеннями
Input = new EditProductInput
{
Id = product.Id,
Name = product.Name,
Price = product.Price,
Description = product.Description
};
return Page();
}
public async Task<IActionResult> OnPostAsync(int id)
{
if (!ModelState.IsValid) return Page();
var success = await _svc.UpdateAsync(id, Input);
return success ? RedirectToPage("./Index") : NotFound();
}
}
ModelState: перевірка валідації
ModelState в Razor Pages — той самий ModelState що і в MVC. Він заповнюється автоматично на основі DataAnnotations до виклику OnPost():
public async Task<IActionResult> OnPostAsync()
{
// ModelState.IsValid = false якщо:
// - порушено [Required], [MaxLength], [Range], [EmailAddress] тощо
// - [BindProperty] мав помилку парсингу (наприклад, "abc" у [Range] decimal)
if (!ModelState.IsValid)
{
// Опціонально: додаємо власні помилки
// ModelState.AddModelError("Input.Name", "Ця назва вже зайнята");
// Повертаємо ту саму сторінку з помилками
// Tag Helper <span asp-validation-for="..."> показає їх автоматично
return Page();
}
// Ручна валідація — якщо щось не можна перевірити через атрибути
var isDuplicate = await _svc.ExistsAsync(Input.Name);
if (isDuplicate)
{
// Додаємо помилку для конкретного поля
ModelState.AddModelError(
"Input.Name",
$"Товар з назвою '{Input.Name}' вже існує");
return Page();
}
// Також можна додати загальну помилку (не прив'язану до поля)
// ModelState.AddModelError(string.Empty, "Щось пішло не так");
await _svc.CreateAsync(Input);
return RedirectToPage("./Index");
}
<form method="post">
<div class="mb-3">
<label asp-for="Input.Name" class="form-label">Назва</label>
<input asp-for="Input.Name" class="form-control">
<!-- Автоматично показує повідомлення помилки для Input.Name -->
<span asp-validation-for="Input.Name" class="text-danger"></span>
</div>
<!-- Загальні помилки (не прив'язані до поля) -->
<div asp-validation-summary="All" class="text-danger"></div>
<button type="submit" class="btn btn-primary">Зберегти</button>
</form>
Повернення результатів: аналоги Results.*
В Minimal API: Results.Ok(), Results.NotFound(), Results.Redirect(). У Razor Pages:
// Рендерити поточну сторінку (.cshtml)
// Аналог: "просто повернути 200 OK з HTML"
return Page();
// Редирект на іншу Razor Page (по назві файлу відносно Pages/)
return RedirectToPage("./Index"); // Pages/Products/Index
return RedirectToPage("/Index"); // Pages/Index (корінь)
return RedirectToPage("./Details", new { id = product.Id }); // З параметрами
// Редирект на URL
return Redirect("/some/url");
return LocalRedirect("/safe-local-url"); // Захищений від open redirect
// HTTP статуси — ті самі що і в Minimal API
return NotFound(); // 404
return BadRequest(); // 400
return BadRequest("Невірний запит");
return Forbid(); // 403
return Unauthorized(); // 401
return StatusCode(429); // Довільний статус
// Повернути JSON (якщо потрібно змішати Razor Pages і API)
return new JsonResult(new { id = 1, name = "test" });
// Повернути файл
return File(fileBytes, "application/pdf", "report.pdf");
return PhysicalFile("/path/to/file.pdf", "application/pdf");
// Порожня відповідь
return new EmptyResult(); // 200 без тіла
TempData: повідомлення після редиректу
TempData — дані що виживають один редирект. Ідеально для flash-повідомлень ("Збережено!"):
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid) return Page();
await _svc.CreateAsync(Input);
// Зберігаємо повідомлення — воно буде доступне після одного Redirect
TempData["SuccessMessage"] = $"Товар '{Input.Name}' успішно створено!";
return RedirectToPage("./Index");
}
public async Task OnGetAsync()
{
Products = await _svc.GetAllAsync();
// TempData["SuccessMessage"] автоматично доступний у view
// після першого прочитання — видаляється
}
@if (TempData["SuccessMessage"] is string message)
{
<div class="alert alert-success alert-dismissible">
@message
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
ViewData: дані для Layout
ViewData — словник даних для передачі між PageModel і View (включаючи Layout). Найпоширеніший випадок — title сторінки:
public async Task OnGetAsync()
{
ViewData["Title"] = "Список товарів";
ViewData["ActiveMenu"] = "products"; // Для підсвітки активного пункту меню
Products = await _svc.GetAllAsync();
}
<title>@ViewData["Title"] — MyShop</title>
<nav>
<a class="@(ViewData["ActiveMenu"]?.ToString() == "products" ? "active" : "")">Товари</a>
</nav>
Page Handlers: кілька форм на одній сторінці
Що якщо на одній сторінці є дві форми? Наприклад, форма редагування товару і форма видалення. Page Handlers — це суфікс до OnPost:
public class DetailsModel : PageModel
{
[BindProperty]
public EditProductInput EditInput { get; set; } = new();
public Product? Product { get; private set; }
public async Task OnGetAsync(int id)
{
Product = await _svc.GetByIdAsync(id);
if (Product is not null)
EditInput = new EditProductInput { Name = Product.Name, Price = Product.Price };
}
// Обробляє POST з ?handler=Update (або asp-page-handler="Update" у формі)
public async Task<IActionResult> OnPostUpdateAsync(int id)
{
if (!ModelState.IsValid) return Page();
await _svc.UpdateAsync(id, EditInput);
TempData["Success"] = "Оновлено!";
return RedirectToPage("./Details", new { id });
}
// Обробляє POST з ?handler=Delete (або asp-page-handler="Delete" у формі)
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
await _svc.DeleteAsync(id);
TempData["Success"] = "Товар видалено";
return RedirectToPage("./Index");
}
}
<!-- Форма редагування — asp-page-handler="Update" -->
<form method="post" asp-page-handler="Update">
<input asp-for="EditInput.Name">
<input asp-for="EditInput.Price">
<button type="submit" class="btn btn-primary">Зберегти</button>
</form>
<!-- Форма видалення — asp-page-handler="Delete" -->
<form method="post" asp-page-handler="Delete">
<button type="submit" class="btn btn-danger"
onclick="return confirm('Видалити товар?')">
Видалити
</button>
</form>
При натисканні кнопки з asp-page-handler="Update" — виконується OnPostUpdateAsync, при "Delete" — OnPostDeleteAsync.
DI у PageModel: різні способи
public class ProductsModel : PageModel
{
// ─── Спосіб 1: через конструктор (рекомендовано) ──────────────
private readonly ProductService _svc;
private readonly ILogger<ProductsModel> _logger;
private readonly IMemoryCache _cache;
public ProductsModel(
ProductService svc,
ILogger<ProductsModel> logger,
IMemoryCache cache)
{
_svc = svc;
_logger = logger;
_cache = cache;
}
// ─── Спосіб 2: [FromServices] — для опціональних залежностей ──
// Аналог [FromServices] у Minimal API
public async Task OnGetAsync(
[FromServices] IExternalApiClient apiClient)
{
// apiClient доступний тільки у цьому методі
var data = await apiClient.FetchAsync();
}
// ─── Спосіб 3: HttpContext.RequestServices ──────────────────
// (Antipattern — уникайте якщо можливо)
public async Task OnGetFallbackAsync()
{
var svc = HttpContext.RequestServices.GetRequiredService<ProductService>();
}
}
Повний приклад: CRUD без зайвого
namespace MyApp.Pages.Products;
public class IndexModel : PageModel
{
private readonly ProductService _svc;
public IndexModel(ProductService svc) => _svc = svc;
[BindProperty(SupportsGet = true)]
public string? Search { get; set; }
[BindProperty(SupportsGet = true)]
public int Page { get; set; } = 1;
public List<Product> Products { get; private set; } = [];
public int TotalCount { get; private set; }
public int PageSize { get; } = 10;
public int TotalPages => (int)Math.Ceiling(TotalCount / (double)PageSize);
public string? SuccessMessage =>
TempData.Peek("SuccessMessage") as string;
public async Task OnGetAsync()
{
(Products, TotalCount) = await _svc.SearchPagedAsync(Search, Page, PageSize);
}
// Видалення прямо зі сторінки списку
public async Task<IActionResult> OnPostDeleteAsync(int id)
{
await _svc.DeleteAsync(id);
TempData["SuccessMessage"] = "Товар видалено";
return RedirectToPage(); // Редирект на ту саму сторінку
}
}
Практичні завдання
Рівень 1 — Базовий
Завдання 1.1. Створіть сторінку Pages/Categories/Index.cshtml з PageModel. OnGetAsync завантажує список категорій із сервісу. OnPostDeleteAsync(int id) видаляє категорію і повертає RedirectToPage(). Після видалення — flash-повідомлення через TempData.
Завдання 1.2. Реалізуйте пошук на Pages/Products/Index.cshtml: [BindProperty(SupportsGet = true)] string? Search, [BindProperty(SupportsGet = true)] int Page = 1. При OnGetAsync — фільтруйте товари за пошуковим запитом і реалізуйте пагінацію. URL при пошуку: /products?search=coffee&page=2.
Рівень 2 — Логіка
Завдання 2.1. Реалізуйте Pages/Products/Edit.cshtml.cs з двома handlers: OnPostSaveAsync(int id) — зберегти зміни, OnPostDuplicateAsync(int id) — створити копію товару з новою назвою. Обидва handler повинні мати власні кнопки у View з asp-page-handler.
Завдання 2.2. Додайте до Pages/Products/Create.cshtml.cs моментальну перевірку дублікату: у OnPostAsync перед збереженням перевіряйте через _svc.ExistsAsync(Input.Name) і якщо існує — ModelState.AddModelError("Input.Name", "Вже існує") + return Page(). Напишіть інтеграційний тест що перевіряє цю поведінку.
Від Minimal API до Razor Pages: концептуальний перехід
Плавний перехід від Minimal API до Razor Pages: навіщо потрібен server-side rendering, коли обирати API vs SSR, порівняльна таблиця концепцій, Convention-based routing через файлову структуру Pages/, перші кроки у Program.cs.
Razor синтаксис: шаблонізатор у .cshtml
Повний огляд Razor синтаксису у .cshtml файлах: @page, @model, @using, C# блоки @{ }, вирази @expression, керуючі конструкції @if/@foreach/@for/@switch, типізований @model, Layouts, @RenderBody/@RenderSection, секції Scripts, часткові подання (Partial Views), ViewComponent, HTML-кодування.