Привет, Хабр! Продолжаю делать интернет магазин на Blazor. В этой части расскажу о том как добавил в него витрину товаров и сделал свои компоненты. За подробностями добро пожаловать под кат.
Содержание
- Blazor + MVVM = Silverlight наносит ответный удар, потому что древнее зло непобедимо
- Blazor Client Side Интернет Магазин: Часть 1 — Авторизация oidc (oauth2) + Identity Server4
- Blazor Client Side Интернет Магазин: Часть 2 — CI/CD
- Blazor Client Side Интернет Магазин: Часть 3 — Витрина товаров
Ссылки
Исходники
Образы на Docker Registry
Обновления
Майкрософт добавили свою библиотеку для авторизации Blazor WebAssembly standalone app with the Authentication library.
Так же добавили возможность создавать приложения с PWA Build Progressive Web Applications

Установить можно нажав на плюсик в хроме:

Выглядит оно вот так:

Так же теперь можно удобно отлаживать код Debug WebAssembly
Добавили возможность делать async Task Main и удалили Startup:
public class Program { public static async Task Main(string[] args) { Console.WriteLine("START MAIN"); var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add<App>("app"); await ConfigureServices(builder.Services); await builder.Build().RunAsync(); Console.WriteLine("END MAIN"); } private static async Task<ConfigModel> GetConfig(IServiceCollection services) { using (var provider = services.BuildServiceProvider()) { var nm = provider.GetRequiredService<NavigationManager>(); var uri = nm.BaseUri; Console.WriteLine($"BASE URI: {uri}"); var url = $"{(uri.EndsWith('/') ? uri : uri + "/")}api/v1/config"; using var client = new HttpClient(); return await client.GetJsonAsync<ConfigModel>(url); } } private static async Task ConfigureServices(IServiceCollection services) { services.AddBaseAddressHttpClient(); var cfg = await GetConfig(services); services.AddScoped<ConfigModel>(s => cfg); Console.WriteLine($"SSO URI IN STARTUP: {cfg?.SsoUri}"); services.AddOidcAuthentication(x => { x.ProviderOptions.Authority = cfg.SsoUri; x.ProviderOptions.ClientId = "spaBlazorClient"; x.ProviderOptions.ResponseType = "code"; x.ProviderOptions.DefaultScopes.Add("api"); x.UserOptions.RoleClaim = "role"; }); services.AddTransient<IAuthorizedHttpClientProvider, AuthorizedHttpClientProvider>(); services.AddTransient<IHttpService, HttpService>(); services.AddTransient<IApiRepository, ApiRepository>(); } }
В общем мелкомягкие молодцы и по моему заслуживают звездочку.
Компоненты
Paginator
Отвечает за постраничную навигацию.
Модель:
public sealed class PaginatorModel { public int Size { get; set; } public int CurrentPage { get; set; } public int ItemsPerPage { get; set; } = 10; public int ItemsTotalCount { get; set; } }
ViewModel + Razor:
<ul class="pagination justify-content-center mx-3 my-3"> <li class="page-item"> <a class="page-link" href="#" @onclick="@(async e => await LoadPage(First))" @onclick:preventDefault><<</a> </li> <li class="page-item"> <a class="page-link" href="#" @onclick="@(async e => await LoadPage(Prev))" @onclick:preventDefault><</a> </li> @{ foreach (var p in Pages) { <li class=@(Model.CurrentPage == p ? "page-item active" : "page-item")> <a class="page-link" @onclick="@(async e => await LoadPage(p))" href="#" @onclick:preventDefault>@(p + 1)</a> </li> } } <li class="page-item"><a class="page-link" href="#" @onclick="@(async e => await LoadPage(Next))" @onclick:preventDefault>></a></li> <li class="page-item"><a class="page-link" href="#" @onclick="@(async e => await LoadPage(Last))" @onclick:preventDefault>>></a></li> <li class="page-item"> <select id="size" class="form-control" value="@Model.ItemsPerPage" @onchange="@OnItemsPerPageChanged"> <option value=10 selected>10</option> <option value=20>20</option> <option value=40>40</option> <option value=80>80</option> </select> </li> </ul> //ViewModel @code { [Parameter] public EventCallback OnPageChanged { get; set; } [Parameter] public PaginatorModel Model { get; set; } public async Task LoadPage(int page) { Model.CurrentPage = page; await OnPageChanged.InvokeAsync(null); } public async Task OnItemsPerPageChanged(ChangeEventArgs x) { Model.ItemsPerPage = int.Parse(x.Value.ToString()); await OnPageChanged.InvokeAsync(null); } public int First => 0; public int Prev => Math.Max(Model.CurrentPage - 1, 0); public int Next => Math.Min(Model.CurrentPage + 1, Math.Max(PageCount - 1, 0)); public int Last => Math.Max(PageCount - 1, 0); public int PageCount { get { if (Model.ItemsPerPage < 1 || Model.ItemsTotalCount < 1) return 0; var count = (Model.ItemsTotalCount / Model.ItemsPerPage); if ((Model.ItemsTotalCount % Model.ItemsPerPage) > 0) count++; return count; } } public IEnumerable<int> Pages { get { var half = Model.Size / 2; var reminder = Model.Size % 2; var max = Math.Min(Model.CurrentPage + half + Math.Max((half - Model.CurrentPage), 0) + reminder, PageCount); var min = Math.Max(max - Model.Size, 0); for (int i = min; i < max; i++) { yield return i; } } } }
Error
Отвечает за отображения ошибок пользователю.
ViewMode + Razor:
@if (!string.IsNullOrWhiteSpace(Model)) { <div class="text-danger"> <h4>Произошла ошибка</h4> <p>Обновите вкладку и повторите позже или обратитесь в поддержку с текстом ошибки:</p> <p>@Model</p> </div> } //ViewModel @code { [Parameter] public string Model { get; set; } }
SortableTableHeader
Отвечает за сортировку данных в таблице.
Модель:
public sealed class SortableTableHeaderModel<TId> { public TId Current { get; set; } public Dictionary<TId, string> Headers { get; set; } public bool Descending { get; set; } }
ViewMode + Razor:
@typeparam TId <thead> <tr> @foreach (var kv in Model.Headers) { <th @onclick="@(x=>Sort(kv.Key))"> @kv.Value <span class="@GetClass(kv.Key)"></span> </th> } </tr> </thead> //ViewModel @code { public Task Sort(TId id) { Model.Current = id; Model.Descending = !Model.Descending; return Sorted.InvokeAsync(null); } public string GetClass(TId id) { if (!id.Equals(Model.Current)) return "d-none"; return Model.Descending ? "oi oi-caret-bottom" : "oi oi-caret-top"; } [Parameter] public SortableTableHeaderModel<TId> Model { get; set; } [Parameter] public EventCallback Sorted { get; set; } }
@typeparam TId
Это генерик параметр тип которого будет иметь ключ по которому мы будет идентифицировать нажатый заголовок столбца. Пока что нет возможности указать ограничения для него. Если была бы возможность я бы повесил что-то вроде where TId:IEquatable
Атрибут для валидации
Встроенная валидация для форм в Blazor работает через атрибуты. Я добавил свой собственный.
Он проверяет что значения у его свойства больше чем значение свойства название которого ему передали в качестве параметра. Я использую его чтобы проверить что максимальная цена больше минимальной цены.
[AttributeUsage(AttributeTargets.Property)] public class GreaterOrEqualToAttribute : ValidationAttribute { public string FieldName { get; } public string DisplayName { get; } public GreaterOrEqualToAttribute(string fieldName, string displayName) { FieldName = fieldName; DisplayName = displayName; } protected override ValidationResult IsValid(object value, ValidationContext validationContext) { if (value is null) return ValidationResult.Success; PropertyInfo otherPropertyInfo = validationContext.ObjectType.GetProperty(FieldName); if (otherPropertyInfo == null) return Fail(validationContext); var otherPropertyValue = otherPropertyInfo.GetValue(validationContext.ObjectInstance, null); if (Comparer.Default.Compare(value, otherPropertyValue) >= 0) return ValidationResult.Success; return Fail(validationContext); } private ValidationResult Fail(ValidationContext validationContext) { return new ValidationResult($"Укажите значение которое больше или равно {DisplayName}", new[] { validationContext.MemberName }); } }
Страница со списком продуктов
Модель:
public sealed class ProductsModel { public PageResultDto<ProductDto> Items { get; set; } = new PageResultDto<ProductDto>() { TotalCount = 0, Value = new List<ProductDto>() }; public bool IsLoaded { get; set; } public PaginatorModel Paginator { get; set; } = new PaginatorModel() { ItemsTotalCount = 0, Size = 5, ItemsPerPage = 10, CurrentPage = 0 }; public SortableTableHeaderModel<ProductOrderBy> TableHeaderModel { get; set; } = new SortableTableHeaderModel<ProductOrderBy>() { Current = ProductOrderBy.Id, Descending = false, Headers = new Dictionary<ProductOrderBy, string>() { {ProductOrderBy.Name,"Title" }, { ProductOrderBy.Price, "Price"} } }; public string HandledErrors { get; set; } public string Title { get; set; } public decimal MinPrice { get; set; } = 0m; public decimal? MaxPrice { get; set; } }
ViewModel:
public class ProductsViewModel : ComponentBase { protected override async Task OnInitializedAsync() { await LoadFromServerAsync(); } [Inject] public IApiRepository Repository { get; set; } public ProductsModel Model { get; set; } = new ProductsModel(); [StringLength(30, ErrorMessage = "Название продукта должно быть короче 30 символов")] public string Title { get; set; } [GreaterOrEqualTo(nameof(Min), "0")] public decimal MinPrice { get; set; } = 0m; public decimal Min => 0m; [GreaterOrEqualTo(nameof(MinPrice), "Min Price")] public decimal? MaxPrice { get; set; } public int Skip => Model.Paginator.ItemsPerPage * Model.Paginator.CurrentPage; public int Take => Model.Paginator.ItemsPerPage; public async Task HandleValidSubmit() { Model.Title = Title; Model.MinPrice = MinPrice; Model.MaxPrice = MaxPrice; Model.Paginator.CurrentPage = 0; await LoadFromServerAsync(); } public async Task LoadPage() { await LoadFromServerAsync(); } public async Task HandleSort() { await LoadFromServerAsync(); } private async Task LoadFromServerAsync() { Model.IsLoaded = false; var dto = new ProductsFilterDto() { Descending = Model.TableHeaderModel.Descending, MinPrice = Model.MinPrice, MaxPrice = Model.MaxPrice ?? decimal.MaxValue, OrderBy = Model.TableHeaderModel.Current, Skip = Skip, Take = Take, Title = Model.Title }; var (r, e) = await Repository.GetFiltered(dto); Model.HandledErrors = e; Model.Items = r ?? new PageResultDto<ProductDto>(); Model.Paginator.ItemsTotalCount = Model.Items.TotalCount; Model.IsLoaded = true; } }
Razor:
@inherits ProductsViewModel @page "/products" <h3>Products</h3> <div class="jumbotron col-md-6"> <EditForm Model="@this" OnValidSubmit="@HandleValidSubmit"> <DataAnnotationsValidator /> <div class="form-row"> <div class="form-group col-md-12"> <label for="title">Title</label> <InputText id="title" @bind-Value="@Title" class="form-control" /> <ValidationMessage For="@(() =>Title)" /> </div> </div> <div class="form-row"> <div class="form-group col-md-6"> <label for="min">Min Price</label> <InputNumber id="min" @bind-Value="@MinPrice" class="form-control" TValue="decimal" /> <ValidationMessage For="@(() => MinPrice)" /> </div> <div class="form-group col-md-6"> <label for="max">Max Price</label> <InputNumber id="max" @bind-Value="@MaxPrice" class="form-control" TValue="decimal?" /> <ValidationMessage For="@(() =>MaxPrice)" /> </div> </div> <button type="submit" class="btn btn-primary" disabled="@(!context.Validate())">Submit</button> </EditForm> </div> <nav aria-label="Table pages"> <Paginator OnPageChanged="@LoadPage" Model="@Model.Paginator" /> </nav> <div> <Error Model="@Model.HandledErrors" /> </div> <div class="table-responsive"> <table class="table"> <SortableTableHeader Sorted="@HandleSort" Model="@Model.TableHeaderModel" TId="ProductOrderBy" /> <tbody> @if (Model.IsLoaded) { @foreach (var product in Model.Items.Value) { <tr> <td>@product.Title</td> <td>@product.Price</td> </tr> } } else { <tr> <td> <p><em>Loading...</em></p> </td> </tr> } </tbody> </table> </div>
Версия на Angular
Использовал PrimeNG Время загрузки Blazor WASM 1.47 против 0.35 секунд в пользу Ангуляра:

ссылка на оригинал статьи https://habr.com/ru/post/494612/
Добавить комментарий