Привет, Хабр! Продолжаю делать интернет магазин на Blazor. В этой части расскажу о том как добавил в него возможность просмотра корзины товаров и организовал работу с состоянием. За подробностями добро пожаловать под кат.
Содержание
- Blazor + MVVM = Silverlight наносит ответный удар, потому что древнее зло непобедимо
- Blazor Client Side Интернет Магазин: Часть 1 — Авторизация oidc (oauth2) + Identity Server4
- Blazor Client Side Интернет Магазин: Часть 2 — CI/CD
- Blazor Client Side Интернет Магазин: Часть 3 — Витрина товаров
- Blazor Client Side Интернет Магазин: Часть 4 — Добавления товара в корзину
- Blazor Client Side Интернет Магазин: Часть 5 — Просмотр корзины и работа с Stateful
Ссылки
→ Исходники
→ Образы на Docker Registry
Stateful
Мне не понравилось что при переходе между страницами теряется состояние. Например те поля по которым я отфильтровал товары. Чтобы решить эту проблему я перешел к stateful сервисам-синглтонам и зарегистрировал ViewModel страницы в DI контейнере как синглтон. По сути я использовал DI контейнер как хранилище состояния, а ViewModel начал инжектить в View как сервис.
Код
1) Models
public sealed class ProductModel
{
public Guid Id { get; set; }
public string Version { get; set; }
public string Title { get; set; }
public decimal Price { get; set; }
}
public class BasketLineModel
{
public uint Quantity { get; set; }
public ProductModel Product { get; set; }
}
public class BasketModel
{
public List<BasketLineModel> Lines { get; set; } = new List<BasketLineModel>();
}
2) Services
public class BasketService : IBasketService
{
private readonly IApiRepository _repository;
private BasketModel _basket;
public BasketService(IApiRepository repository)
{
_repository = repository;
_basket = new BasketModel();
}
public string Error { get; private set; }
public IReadOnlyList<BasketLineModel> Model => _basket.Lines.AsReadOnly();
public event EventHandler OnBasketItemsCountChanged;
public long ItemsCount => _basket?.Lines?.Sum(l => l.Quantity) ?? 0;
public async Task Load()
{
var count = ItemsCount;
var (r, e) = await _repository.GetBasket();
_basket = r;
Error = e;
if (string.IsNullOrWhiteSpace(Error) && count != ItemsCount)
OnBasketItemsCountChanged?.Invoke(null, null);
}
public async Task Add(ProductModel product)
{
var (_, e) = await _repository.AddToBasket(product);
Error = e;
if (!string.IsNullOrWhiteSpace(e))
return;
await Load();
}
public async Task Remove(ProductModel product)
{
var (_, e) = await _repository.Remove(product.Id);
Error = e;
if (!string.IsNullOrWhiteSpace(e))
return;
await Load();
}
}
Тут надо рассказать про
public event EventHandler OnBasketItemsCountChanged;
Я хотел в заголовке страницы отображать текущее количество товаров в корзине. Проблема в том что заголовок не является дочерним элементом по отношению к странице корзины покупок поэтому обновлении ее стояния он игнорирует. Чтобы он перерисовывался я и добавил это событие, а в нем повесил вот такой обработчик:
@using BlazorEShop.Spa.BlazorWasm.Client.Core.Services
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject NavigationManager Navigation
@inject SignOutSessionStateManager SignOutManager
@inject IBasketService Basket
@implements IDisposable
<AuthorizeView>
<Authorized>
<span class="text-success">Total Items In Basket: @TotalItemsCount </span>
Hello, @context?.User?.Identity?.Name!
<button class="nav-link btn btn-link" @onclick="BeginSignOut">Log out</button>
</Authorized>
<NotAuthorized>
<a href="authentication/login">Log in</a>
</NotAuthorized>
</AuthorizeView>
@code
{
public long TotalItemsCount { get; set; }
protected override void OnInitialized()
{
Basket.OnBasketItemsCountChanged += Bind;
TotalItemsCount = Basket.ItemsCount;
base.OnInitialized();
}
public void Dispose()
{
Basket.OnBasketItemsCountChanged -= Bind;
}
public void Bind(object s, EventArgs e)
{
if (TotalItemsCount == Basket.ItemsCount)
return;
TotalItemsCount = Basket.ItemsCount;
this.StateHasChanged();
}
private async Task BeginSignOut(MouseEventArgs args)
{
await SignOutManager.SetSignOutState();
Navigation.NavigateTo("authentication/logout");
}
}
3) ViewModel
public class BasketViewModel
{
private bool _isInitialized;
private readonly IBasketService _service;
public BasketViewModel(IBasketService service)
{
_service = service;
}
public string Error => _service.Error;
public IReadOnlyList<BasketLineModel> Model => _service.Model;
public async Task OnInitializedAsync()
{
if (_isInitialized)
return;
Console.WriteLine("BASKET INIT!");
await _service.Load();
_isInitialized = true;
}
public Task Add(ProductModel product) => _service.Add(product);
public Task Remove(ProductModel product) => _service.Remove(product);
}
4) View
@page "/basket"
@attribute [Authorize]
@inject BasketViewModel ViewModel
<h3>Basket</h3>
<Error Model="@ViewModel.Error" />
<input type="button" class="btn btn-primary my-3" value="Create Order" /> <!--TODO: реализовать создание заказа-->
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Price</th>
<th>Quantity</th>
<th></th>
</tr>
</thead>
<tbody>
@if (ViewModel.Model == null)
{
<tr>
<td>
<em>Loading...</em>
</td>
</tr>
}
else
{
foreach (var line in ViewModel.Model)
{
<tr>
<td>@line.Product.Title</td>
<td>@line.Product.Price</td>
<td>@line.Quantity</td>
<td>
<input type="button"
class="btn btn-success"
@onclick="@(async x=>await ViewModel.Add(line.Product))"
value="+" />
<input class="btn btn-warning"
value="-"
type="button"
@onclick="@(async x=>await ViewModel.Remove(line.Product))" />
</td>
</tr>
}
}
</tbody>
</table>
</div>
@code
{
protected override async Task OnInitializedAsync()
{
await ViewModel.OnInitializedAsync();
}
}
5) Регистрация в DI контейнере
services.AddTransient<IApiRepository, ApiRepository>();
services.AddSingleton<IBasketService, BasketService>();
services.AddSingleton<BasketViewModel>();
Вариант на Angular 9
Пока что разработка на Blazor мне больше удовольствия приносит чем Ангуляр.