Привет, Хабр! Продолжаю делать интернет магазин на 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 секунд в пользу Ангуляра: