Привет, Хабр! Продолжаю делать интернет магазин на Blazor. В этой части расскажу о том как добавил в него витрину товаров и сделал свои компоненты. За подробностями добро пожаловать под кат.

Содержание



Ссылки


Исходники
> Образы на 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 секунд в пользу Ангуляра: