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

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 0

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое