Blazor Client Side Интернет Магазин: Часть 3 — Витрина товаров

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

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр